diff --git a/.github/pyrightconfig.json b/.github/pyrightconfig.json new file mode 100644 index 0000000..6ceb2c0 --- /dev/null +++ b/.github/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "typeCheckingMode": "strict", + "pythonVersion": "3.13", + "include": [ + "scripts/*.py" + ], + "reportMissingTypeStubs": "error", + "reportUnnecessaryTypeIgnoreComment": "error", + "reportImplicitOverride": "error", + "exclude": [ + "**/__pycache__" + ], + "venvPath": "..", + "venv": ".venv" +} diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py index 8705490..7177466 100644 --- a/.github/scripts/check-codeql-sarif.py +++ b/.github/scripts/check-codeql-sarif.py @@ -2,55 +2,107 @@ # SPDX-License-Identifier: Apache-2.0 """Reject missing/malformed CodeQL evidence before enforcing the severity gate.""" + import json import math import os from pathlib import Path +from typing import TypedDict + +RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False) -def check(directory, language, threshold): - if not math.isfinite(threshold) or not 0 <= threshold <= 10: - raise ValueError("Invalid security severity threshold") - canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language) - files = sorted(Path(directory).glob("*.sarif")) - if not canonical or not any(path.name == canonical + ".sarif" for path in files): - raise ValueError("Missing expected CodeQL SARIF report for " + language) - blocking = [] - for path in files: - report = json.loads(path.read_text(encoding="utf-8")) - runs = report.get("runs") - if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs: - raise ValueError("Invalid or empty SARIF runs: " + path.name) - for run in runs: - driver = run.get("tool", {}).get("driver", {}) - results = run.get("results") - if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list): - raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name) - rules = {} - for component in [driver, *run.get("tool", {}).get("extensions", [])]: - for rule in component.get("rules", []): - rules[rule["id"]] = rule - if not rules: - raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name) - for invocation in run.get("invocations", []): - if invocation.get("executionSuccessful") is False: - raise ValueError("CodeQL invocation failed: " + path.name) - for result in results: - rule_id = result.get("ruleId") or result.get("rule", {}).get("id") - if rule_id not in rules: - raise ValueError("Result references missing rule metadata: " + path.name) - if result.get("suppressions"): - continue - severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0) - if not math.isfinite(severity) or not 0 <= severity <= 10: - raise ValueError("Invalid rule severity: " + rule_id) - if severity >= threshold: - blocking.append((severity, rule_id)) - for severity, rule_id in sorted(blocking, reverse=True): - print(f"::error title=CodeQL security finding::{rule_id} ({severity})") - print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)") - return bool(blocking) +class Rule(TypedDict): + """Keep the rule identity and optional severity metadata used by the gate.""" + + id: str + properties: RuleProperties + + +class Component(TypedDict, total=False): + """Describe CodeQL driver or extension rule metadata.""" + + name: str + rules: list[Rule] + + +class Tool(TypedDict, total=False): + """Group the driver and optional extension rule catalogs.""" + + driver: Component + extensions: list[Component] + + +class Result(TypedDict, total=False): + """Describe only the result fields consumed by the severity gate.""" + + ruleId: str + rule: dict[str, str] + suppressions: list[object] + + +class Run(TypedDict, total=False): + """Retain invocation success and findings for one analyzed language.""" + + tool: Tool + results: list[Result] + invocations: list[dict[str, bool]] + + +class Report(TypedDict, total=False): + """Represent SARIF fields while retaining the explicit runtime validation.""" + + version: str + runs: list[Run] + + +MAX_SEVERITY = 10 + + +def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped. + """Validate complete CodeQL evidence and report findings at the threshold.""" + if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY: + raise ValueError("Invalid security severity threshold") + canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language) + files = sorted(Path(directory).glob("*.sarif")) + if not canonical or not any(path.name == canonical + ".sarif" for path in files): + raise ValueError("Missing expected CodeQL SARIF report for " + language) + blocking: list[tuple[float, str]] = [] + for path in files: + report: Report = json.loads(path.read_text(encoding="utf-8")) + runs = report.get("runs") + if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs: + raise ValueError("Invalid or empty SARIF runs: " + path.name) + for run in runs: + driver = run.get("tool", {}).get("driver", {}) + results = run.get("results") + if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list): + raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name) + rules: dict[str, Rule] = {} + for component in [driver, *run.get("tool", {}).get("extensions", [])]: + for rule in component.get("rules", []): + rules[rule["id"]] = rule + if not rules: + raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name) + for invocation in run.get("invocations", []): + if invocation.get("executionSuccessful") is False: + raise ValueError("CodeQL invocation failed: " + path.name) + for result in results: + rule_id = result.get("ruleId") or result.get("rule", {}).get("id") + if rule_id not in rules: + raise ValueError("Result references missing rule metadata: " + path.name) + if result.get("suppressions"): + continue + severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0) + if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY: + raise ValueError("Invalid rule severity: " + rule_id) + if severity >= threshold: + blocking.append((severity, rule_id)) + for severity, rule_id in sorted(blocking, reverse=True): + print(f"::error title=CodeQL security finding::{rule_id} ({severity})") + print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)") + return bool(blocking) if __name__ == "__main__": - raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) + raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) diff --git a/.github/scripts/check-maven-inventory.py b/.github/scripts/check-maven-inventory.py index ffaac49..616f308 100644 --- a/.github/scripts/check-maven-inventory.py +++ b/.github/scripts/check-maven-inventory.py @@ -2,127 +2,172 @@ # SPDX-License-Identifier: Apache-2.0 """Fail closed unless scanners cover Maven's selected UI-host runtime graph.""" + import argparse -from collections import Counter import json -from pathlib import Path import re +from collections import Counter +from pathlib import Path +from typing import TypedDict, cast from urllib.parse import unquote, urlsplit +class Bom(TypedDict, total=False): + """Describe the selected CycloneDX coordinate fields.""" + + bomFormat: str + components: list[dict[str, str]] + metadata: dict[str, dict[str, str]] + + +class TrivyReport(TypedDict, total=False): + """Describe package coordinates, leaving runtime shape checks intact.""" + + Results: list[dict[str, list[dict[str, str]]]] + + +class OsvReport(TypedDict, total=False): + """Keep result bodies untrusted until their explicit validation.""" + + results: list[object] + + +class OsvResult(TypedDict, total=False): + """Describe source identity and packages after checking the object shape.""" + + source: dict[str, str] + packages: list[object] + + +class OsvEntry(TypedDict, total=False): + """Describe the coordinate and optional vulnerability array being checked.""" + + package: dict[str, str] + vulnerabilities: object + + REQUIRED = { - "com.kingsrook.qqq:qqq-frontend-next", - "com.kingsrook.qqq:qqq-backend-core", - "com.kingsrook.qqq:qqq-backend-module-filesystem", - "com.google.guava:guava", - "com.h2database:h2", + "com.kingsrook.qqq:qqq-frontend-next", + "com.kingsrook.qqq:qqq-backend-core", + "com.kingsrook.qqq:qqq-backend-module-filesystem", + "com.google.guava:guava", + "com.h2database:h2", } REVIEWED_H2_VERSION = "2.4.240" OMITTED = object() -def resolved(text): - packages = set() - for line in text.splitlines(): - line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip().split(" -- ", 1)[0].strip() - if not line or line == "The following files have been resolved:": - continue - parts = line.split(":") - if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"): - raise ValueError(f"Unexpected Maven runtime coordinate: {line}") - if not all(parts) or parts[2] != "jar": - raise ValueError(f"Invalid runtime JAR coordinate: {line}") - name = parts[0] + ":" + parts[1] - if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")): - raise ValueError("Test library in runtime inventory") - packages.add((name, parts[-2])) - if not REQUIRED.issubset({name for name, _ in packages}): - raise ValueError("Maven inventory is empty or lacks required UI-host dependencies") - # The pinned dependency-review license exception matcher ignores versions. - # Keep any H2 metadata exception limited to the reviewed, unmodified runtime. - if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}: - raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION) - return packages +def resolved(text: str) -> set[tuple[str, str]]: + """Read Maven's selected runtime JARs and enforce the reviewed H2 pin.""" + packages: set[tuple[str, str]] = set() + for raw_line in text.splitlines(): + line = re.sub(r"\x1b\[[0-9;]*m", "", raw_line).strip().split(" -- ", 1)[0].strip() + if not line or line == "The following files have been resolved:": + continue + parts = line.split(":") + if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"): + raise ValueError(f"Unexpected Maven runtime coordinate: {line}") + if not all(parts) or parts[2] != "jar": + raise ValueError(f"Invalid runtime JAR coordinate: {line}") + name = parts[0] + ":" + parts[1] + if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")): + raise ValueError("Test library in runtime inventory") + packages.add((name, parts[-2])) + if not REQUIRED.issubset({name for name, _ in packages}): + raise ValueError("Maven inventory is empty or lacks required UI-host dependencies") + # The pinned dependency-review license exception matcher ignores versions. + # Keep any H2 metadata exception limited to the reviewed, unmodified runtime. + if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}: + raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION) + return packages -def components(report): - if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX": - raise ValueError("Expected CycloneDX inventory") - packages = set() - for component in report.get("components", []): - purl = urlsplit(component.get("purl", "")) - if purl.scheme != "pkg" or not purl.path.startswith("maven/"): - raise ValueError("Missing Maven package URL") - coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1) - group, artifact = coordinate.split("/", 1) - packages.add((unquote(group) + ":" + unquote(artifact), unquote(version))) - if not packages: - raise ValueError("Empty CycloneDX inventory") - return packages +def components(report: object) -> set[tuple[str, str]]: + """Extract exact Maven coordinates from the scanner's CycloneDX inventory.""" + typed_report = cast(Bom, report) + if not isinstance(report, dict) or typed_report.get("bomFormat") != "CycloneDX": + raise ValueError("Expected CycloneDX inventory") + packages: set[tuple[str, str]] = set() + for component in typed_report.get("components", []): + purl = urlsplit(component.get("purl", "")) + if purl.scheme != "pkg" or not purl.path.startswith("maven/"): + raise ValueError("Missing Maven package URL") + coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1) + group, artifact = coordinate.split("/", 1) + packages.add((unquote(group) + ":" + unquote(artifact), unquote(version))) + if not packages: + raise ValueError("Empty CycloneDX inventory") + return packages -def check(expected, bom, trivy=OMITTED): - actual = components(bom) - if actual != expected: - raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}") - if trivy is not OMITTED: - if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list): - raise ValueError("Expected Trivy report object with Results array") - scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])} - # Trivy also reports the root project represented by SBOM metadata. - root = bom.get("metadata", {}).get("component", {}) - allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))} - if not expected.issubset(scanned) or not scanned.issubset(allowed): - raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}") - return len(expected) +def check(expected: set[tuple[str, str]], bom: object, trivy: object = OMITTED) -> int: + """Require exact SBOM coverage and, when supplied, selected Trivy coverage.""" + actual = components(bom) + if actual != expected: + raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}") + if trivy is not OMITTED: + typed_trivy = cast(TrivyReport, trivy) + if not isinstance(trivy, dict) or not isinstance(typed_trivy.get("Results"), list): + raise ValueError("Expected Trivy report object with Results array") + scanned = {(p["Name"], p["Version"]) for r in typed_trivy.get("Results", []) for p in r.get("Packages", [])} + # Trivy also reports the root project represented by SBOM metadata. + root = cast(Bom, bom).get("metadata", {}).get("component", {}) + allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))} + if not expected.issubset(scanned) or not scanned.issubset(allowed): + raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}") + return len(expected) -def check_osv(expected, report): - if not isinstance(report, dict) or not isinstance(report.get("results"), list): - raise ValueError("Expected OSV report object with results array") - # OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names: - # https://github.com/google/osv-scanner/issues/3099 - # Fail on ambiguous display coordinates instead of claiming full identity proof. - projected = Counter((name.split(":", 1)[1], version) for name, version in expected) - if any(count != 1 for count in projected.values()): - raise ValueError("Ambiguous Maven coordinates in pinned OSV report format") - scanned = Counter() - for result in report["results"]: - if not isinstance(result, dict) or not isinstance(result.get("source"), dict): - raise ValueError("Missing OSV source") - if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str): - raise ValueError("Expected OSV SBOM source") - if not isinstance(result.get("packages"), list): - raise ValueError("Missing OSV package inventory; use --all-packages") - for entry in result["packages"]: - if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict): - raise ValueError("Malformed OSV package") - package = entry["package"] - if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")): - raise ValueError("Invalid OSV Maven package coordinate") - if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list): - raise ValueError("Malformed OSV vulnerabilities") - scanned[(package["name"], package["version"])] += 1 - if scanned != projected: - raise ValueError("OSV --all-packages inventory differs from selected Maven graph") - return len(expected) +def check_osv(expected: set[tuple[str, str]], report: object) -> int: + """Reject missing, ambiguous, malformed or partial OSV package evidence.""" + typed_report = cast(OsvReport, report) + if not isinstance(report, dict) or not isinstance(typed_report.get("results"), list): + raise ValueError("Expected OSV report object with results array") + # OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names: + # https://github.com/google/osv-scanner/issues/3099 + # Fail on ambiguous display coordinates instead of claiming full identity proof. + projected = Counter((name.split(":", 1)[1], version) for name, version in expected) + if any(count != 1 for count in projected.values()): + raise ValueError("Ambiguous Maven coordinates in pinned OSV report format") + scanned: Counter[tuple[str, str]] = Counter() + for raw_result in cast(list[object], typed_report.get("results")): + result = cast(OsvResult, raw_result) + if not isinstance(raw_result, dict) or not isinstance(result.get("source"), dict): + raise ValueError("Missing OSV source") + source = cast(dict[str, str], result.get("source")) + if source.get("type") != "sbom" or not isinstance(source.get("path"), str): + raise ValueError("Expected OSV SBOM source") + if not isinstance(result.get("packages"), list): + raise ValueError("Missing OSV package inventory; use --all-packages") + for raw_entry in cast(list[object], result.get("packages")): + entry = cast(OsvEntry, raw_entry) + if not isinstance(raw_entry, dict) or not isinstance(entry.get("package"), dict): + raise ValueError("Malformed OSV package") + package = cast(dict[str, str], entry.get("package")) + if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")): + raise ValueError("Invalid OSV Maven package coordinate") + if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list): + raise ValueError("Malformed OSV vulnerabilities") + scanned[(package["name"], package["version"])] += 1 + if scanned != projected: + raise ValueError("OSV --all-packages inventory differs from selected Maven graph") + return len(expected) -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("resolved", type=Path) - parser.add_argument("bom", type=Path) - parser.add_argument("trivy", type=Path, nargs="?") - parser.add_argument("--osv", type=Path) - args = parser.parse_args() - expected = resolved(args.resolved.read_text(encoding="utf-8")) - count = check(expected, - json.loads(args.bom.read_text(encoding="utf-8")), - json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED) - if args.osv: - check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8"))) - print(f"Verified {count} Maven runtime dependencies, including the UI frontend.") +def main() -> None: + """Validate the exact evidence files supplied by the required CI job.""" + parser = argparse.ArgumentParser() + parser.add_argument("resolved", type=Path) + parser.add_argument("bom", type=Path) + parser.add_argument("trivy", type=Path, nargs="?") + parser.add_argument("--osv", type=Path) + args = parser.parse_args() + expected = resolved(args.resolved.read_text(encoding="utf-8")) + count = check(expected, json.loads(args.bom.read_text(encoding="utf-8")), json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED) + if args.osv: + check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8"))) + print(f"Verified {count} Maven runtime dependencies, including the UI frontend.") if __name__ == "__main__": - main() + main() diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py new file mode 100644 index 0000000..18385f0 --- /dev/null +++ b/.github/scripts/check-script-quality.py @@ -0,0 +1,112 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Any + +PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") +SCRIPT_PATTERN = ".github/scripts/*.py" +PYRIGHT_CONFIG = ".github" +JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") +# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`. +PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js" + + +def tracked_files(root: Path) -> list[str]: + """List tracked and not-yet-ignored new paths so no source file can silently bypass the gates.""" + # Fixed Git inventory operation; the root is an argument, never shell input. + output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + return [name for name in output.split("\u0000") if name] + + +def python_scope(tracked: list[str]) -> list[str]: + """Return the tracked Python sources, rejecting any outside the one checked script root.""" + candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES)) + outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)] + if outside: + raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned Python scripts selected") + return candidates + + +def javascript_scope(root: Path, tracked: list[str]) -> list[str]: + """Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list.""" + candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES)) + tsconfig = root / "tsconfig.json" + if not tsconfig.is_file(): + if candidates: + raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates)) + return [] + includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"] + outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)] + if outside: + raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned JavaScript sources selected") + return candidates + + +def run_pyright(root: Path, config: str) -> dict[str, Any]: + """Run the locked npm Pyright through Node with JSON output and return its full report.""" + node = shutil.which("node") + if node is None: + raise ValueError("Node.js is required to run the locked Pyright") + if not PYRIGHT_ENTRY.is_file(): + raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})") + # Resolved Node and locked entry script; the config is an argument, never shell input. + completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell. + try: + report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :]) + int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing. + report["exitCode"] = completed.returncode + except (ValueError, KeyError) as error: + raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error + return report + + +def check_pyright_report(report: dict[str, Any], expected: int) -> None: + """Require the analyzed-file count to match the selected sources, with no errors or warnings.""" + summary = report["summary"] + analyzed = summary["filesAnalyzed"] + if analyzed == 0 or analyzed != expected: + raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}") + if summary["errorCount"] or summary["warningCount"]: + details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]] + raise ValueError("Pyright reported findings:\n" + "\n".join(details)) + if report["exitCode"] != 0: + raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary") + + +def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None: + """Run Ruff on the explicit source list so no selected file can be skipped.""" + # Fixed interpreter module invocation; paths are arguments, never shell input. + if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell. + raise ValueError("Ruff " + " ".join(arguments) + " failed") + + +def main() -> None: + """Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count.""" + root = Path(__file__).resolve().parents[2] + tracked = tracked_files(root) + sources = python_scope(tracked) + javascript = javascript_scope(root, tracked) + run_ruff(root, ["format", "--check"], sources) + run_ruff(root, ["check"], sources) + report = run_pyright(root, PYRIGHT_CONFIG) + check_pyright_report(report, len(sources)) + print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/check-source-headers.py b/.github/scripts/check-source-headers.py index 2392c7e..e98fd8f 100644 --- a/.github/scripts/check-source-headers.py +++ b/.github/scripts/check-source-headers.py @@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool: return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"]) -def leading_comments(text: str, style: str) -> str: +def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch. """Read comments only, preserving required interpreter/XML directive ordering.""" text = text.removeprefix("\ufeff") if text.startswith("", text) if style != "xml" or declaration is None: raise ValueError("Invalid XML declaration") - text = text[declaration.end():] + text = text[declaration.end() :] elif text.startswith("#!"): if style not in ("hash", "powershell", "slash"): raise ValueError("Unexpected interpreter directive") @@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str: if style != "powershell": raise ValueError("Unexpected PowerShell directive") text = text.partition("\n")[2] - comments = [] + comments: list[str] = [] while text.strip(): text = text.lstrip() if text.startswith(" str: elif style == "slash" and text.startswith("//"): line, _, text = text.partition("\n") comments.append(line[2:]) - elif ((style == "slash" and text.startswith("/*")) - or (style == "xml" and text.startswith(""), "powershell": ("<#", "#>")}[style] - body, separator, rest = text[len(opening):].partition(closing) + body, separator, rest = text[len(opening) :].partition(closing) if not separator: raise ValueError("Unterminated leading comment") comments.append(body) @@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])] for notice in approved: holder = re.escape(notice["holder"]) - copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$" + copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$" if re.search(copyright_pattern, comments) is None: continue spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments) if spdx and any(value != notice["license"] for value in spdx): continue - apache = all(phrase in comments for phrase in ( - "Licensed under the Apache License, Version 2.0", - "you may not use this file except in compliance with the License.", - "https://www.apache.org/licenses/LICENSE-2.0", - "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND", - "limitations under the License.", - )) + apache = all( + phrase in comments + for phrase in ( + "Licensed under the Apache License, Version 2.0", + "you may not use this file except in compliance with the License.", + "https://www.apache.org/licenses/LICENSE-2.0", + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND", + "limitations under the License.", + ) + ) if suffix == ".java" and not apache: continue if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache): @@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: def check_repository(root: Path, policy: dict[str, Any]) -> int: """Check tracked sources without scanning build outputs or executing source.""" - tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") - failures = [] + # Fixed Git inventory operation; paths are arguments, never shell input. + tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + failures: list[str] = [] count = 0 for name in tracked: if not name or not selected(name, policy): diff --git a/.github/scripts/test-maven-inventory.py b/.github/scripts/test-maven-inventory.py index 8bd5c77..981908a 100644 --- a/.github/scripts/test-maven-inventory.py +++ b/.github/scripts/test-maven-inventory.py @@ -2,107 +2,117 @@ # SPDX-License-Identifier: Apache-2.0 """Synthetic regressions for missing, incorrect and empty scanner inventories.""" + import copy import importlib.util -from pathlib import Path -import sys -import subprocess -import tempfile import json +import subprocess +import sys +import tempfile import unittest - +from pathlib import Path +from typing import Any, cast, override sys.dont_write_bytecode = True spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py")) +assert spec is not None and spec.loader is not None inventory = importlib.util.module_from_spec(spec) spec.loader.exec_module(inventory) class InventoryTests(unittest.TestCase): - def setUp(self): - self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")} - self.text = "The following files have been resolved:\n" + "\n".join( - f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected) - self.bom = {"bomFormat": "CycloneDX", "components": [ - {"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} - for name, version in self.expected]} - self.trivy = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]} + """Exercise selected dependency coverage using isolated synthetic reports.""" - def test_complete_graph_without_findings_passes(self): - self.assertEqual(self.expected, inventory.resolved(self.text)) - self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy)) + @override + def setUp(self) -> None: + """Create isolated synthetic evidence with cleanup after each test.""" + self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")} + self.text = "The following files have been resolved:\n" + "\n".join(f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected) + self.bom: dict[str, Any] = {"bomFormat": "CycloneDX", "components": [{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} for name, version in self.expected]} + self.trivy: dict[str, Any] = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]} - def test_missing_or_unreviewed_h2_version_fails(self): - without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line) - for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), - self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"): - with self.subTest(text=text), self.assertRaises(ValueError): - inventory.resolved(text) + def test_complete_graph_without_findings_passes(self) -> None: + """Verify complete graph without findings passes.""" + self.assertEqual(self.expected, inventory.resolved(self.text)) + self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy)) - def test_missing_frontend_and_empty_maven_fail(self): - for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)): - with self.assertRaises(ValueError): - inventory.resolved(text) + def test_missing_or_unreviewed_h2_version_fails(self) -> None: + """Verify missing or unreviewed h2 version fails.""" + without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line) + for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"): + with self.subTest(text=text), self.assertRaises(ValueError): + inventory.resolved(text) - def test_test_scope_or_test_jar_fails(self): - for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"): - with self.assertRaises(ValueError): - inventory.resolved(self.text + "\n" + extra) + def test_missing_frontend_and_empty_maven_fail(self) -> None: + """Verify missing frontend and empty maven fail.""" + for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)): + with self.assertRaises(ValueError): + inventory.resolved(text) - def test_empty_malformed_or_missing_component_fails(self): - for bom in ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, - {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]}): - with self.assertRaises(ValueError): - inventory.check(self.expected, bom) + def test_test_scope_or_test_jar_fails(self) -> None: + """Verify test scope or test jar fails.""" + for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"): + with self.assertRaises(ValueError): + inventory.resolved(self.text + "\n" + extra) - def test_wrong_selected_version_fails(self): - bad = copy.deepcopy(self.trivy) - for package in bad["Results"][0]["Packages"]: - if package["Name"] == "org.jspecify:jspecify": - package["Version"] = "1.0.1" - with self.assertRaises(ValueError): - inventory.check(self.expected, self.bom, bad) + def test_empty_malformed_or_missing_component_fails(self) -> None: + """Verify empty malformed or missing component fails.""" + for bom in cast(tuple[object, ...], ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]})): + with self.assertRaises(ValueError): + inventory.check(self.expected, bom) - def test_empty_or_dropped_trivy_package_fails(self): - for report in (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]}): - with self.assertRaises(ValueError): - inventory.check(self.expected, self.bom, report) + def test_wrong_selected_version_fails(self) -> None: + """Verify wrong selected version fails.""" + bad = copy.deepcopy(self.trivy) + for package in bad["Results"][0]["Packages"]: + if package["Name"] == "org.jspecify:jspecify": + package["Version"] = "1.0.1" + with self.assertRaises(ValueError): + inventory.check(self.expected, self.bom, bad) - def test_cli_supplied_null_trivy_fails(self): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - (directory / "resolved.txt").write_text(self.text, encoding="utf-8") - (directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8") - (directory / "trivy.json").write_text("null", encoding="utf-8") - result = subprocess.run([sys.executable, str(Path(inventory.__file__)), - str(directory / "resolved.txt"), str(directory / "bom.json"), - str(directory / "trivy.json")], capture_output=True, text=True, check=False) - self.assertNotEqual(0, result.returncode) - self.assertIn("Expected Trivy report object", result.stderr) + def test_empty_or_dropped_trivy_package_fails(self) -> None: + """Verify empty or dropped trivy package fails.""" + for report in cast(tuple[object, ...], (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]})): + with self.assertRaises(ValueError): + inventory.check(self.expected, self.bom, report) - def osv(self): - return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [ - {"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} - for name, version in self.expected]}]} + def test_cli_supplied_null_trivy_fails(self) -> None: + """Verify cli supplied null trivy fails.""" + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory / "resolved.txt").write_text(self.text, encoding="utf-8") + (directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8") + (directory / "trivy.json").write_text("null", encoding="utf-8") + assert inventory.__file__ is not None + result = subprocess.run([sys.executable, str(Path(inventory.__file__)), str(directory / "resolved.txt"), str(directory / "bom.json"), str(directory / "trivy.json")], capture_output=True, text=True, check=False) # noqa: S603 -- Execute this interpreter and the fixed sibling guard using synthetic paths, without a shell. + self.assertNotEqual(0, result.returncode) + self.assertIn("Expected Trivy report object", result.stderr) - def test_osv_all_clean_packages_pass_without_vulnerabilities(self): - self.assertEqual(6, inventory.check_osv(self.expected, self.osv())) + def osv(self) -> dict[str, Any]: + """Build a clean OSV fixture whose identity matches the selected graph.""" + return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} for name, version in self.expected]}]} - def test_osv_missing_malformed_or_partial_evidence_fails(self): - partial = self.osv() - partial["results"][0]["packages"].pop() - wrong = self.osv() - wrong["results"][0]["packages"][0]["package"]["version"] = "wrong" - duplicate = self.osv() - duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0]) - for report in (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate): - with self.subTest(report=report), self.assertRaises(ValueError): - inventory.check_osv(self.expected, report) + def test_osv_all_clean_packages_pass_without_vulnerabilities(self) -> None: + """Verify osv all clean packages pass without vulnerabilities.""" + self.assertEqual(6, inventory.check_osv(self.expected, self.osv())) - def test_osv_ambiguous_group_projection_fails(self): - with self.assertRaises(ValueError): - inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv()) + def test_osv_missing_malformed_or_partial_evidence_fails(self) -> None: + """Verify osv missing malformed or partial evidence fails.""" + partial = self.osv() + partial["results"][0]["packages"].pop() + wrong = self.osv() + wrong["results"][0]["packages"][0]["package"]["version"] = "wrong" + duplicate = self.osv() + duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0]) + for report in cast(tuple[object, ...], (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate)): + with self.subTest(report=report), self.assertRaises(ValueError): + inventory.check_osv(self.expected, report) + + def test_osv_ambiguous_group_projection_fails(self) -> None: + """Verify osv ambiguous group projection fails.""" + with self.assertRaises(ValueError): + inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv()) if __name__ == "__main__": - unittest.main() + unittest.main() diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py index c8494eb..2b173dd 100644 --- a/.github/scripts/test-scan-gates.py +++ b/.github/scripts/test-scan-gates.py @@ -2,91 +2,106 @@ # SPDX-License-Identifier: Apache-2.0 """Exercise the exact CI guards using synthetic evidence only.""" + import contextlib import copy -import io import importlib.util +import io import json -from pathlib import Path import sys import tempfile import unittest - +from pathlib import Path +from types import ModuleType +from typing import Any, override sys.dont_write_bytecode = True -def load(name): - spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +def load(name: str) -> ModuleType: + """Load the real sibling guard for synthetic evidence tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module codeql = load("check-codeql-sarif") class SarifTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.directory = Path(self.temporary.name) - self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} + """Reject incomplete SARIF evidence and preserve severity enforcement.""" - def save(self, report): - (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") + @override + def setUp(self) -> None: + """Create isolated synthetic evidence with cleanup after each test.""" + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} - def test_clean_report_with_zero_findings_passes(self): - self.save(self.report) - self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) + def save(self, report: object) -> None: + """Write the test body as SARIF without validating it first.""" + (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") - def test_missing_report_or_wrong_language_fails(self): - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "csharp", 7) + def test_clean_report_with_zero_findings_passes(self) -> None: + """Verify clean report with zero findings passes.""" + self.save(self.report) + self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) - def test_empty_or_malformed_report_fails(self): - for text in ["", "{", "null", "[]"]: - with self.subTest(text=text): - (self.directory / "java.sarif").write_text(text) - with self.assertRaises((ValueError, AttributeError)): - codeql.check(self.directory, "java-kotlin", 7) + def test_missing_report_or_wrong_language_fails(self) -> None: + """Verify missing report or wrong language fails.""" + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "csharp", 7) - def test_missing_runs_tool_rules_or_results_fails(self): - variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}] - for field in ["tool", "results"]: - bad = copy.deepcopy(self.report) - del bad["runs"][0][field] - variants.append(bad) - bad = copy.deepcopy(self.report) - bad["runs"][0]["tool"]["driver"]["rules"] = [] - variants.append(bad) - for bad in variants: - with self.subTest(report=bad): - self.save(bad) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) + def test_empty_or_malformed_report_fails(self) -> None: + """Verify empty or malformed report fails.""" + for text in ["", "{", "null", "[]"]: + with self.subTest(text=text): + (self.directory / "java.sarif").write_text(text) + with self.assertRaises((ValueError, AttributeError)): + codeql.check(self.directory, "java-kotlin", 7) - def test_high_finding_blocks_and_unknown_rule_fails(self): - self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}] - self.save(self.report) - with contextlib.redirect_stdout(io.StringIO()): - self.assertTrue(codeql.check(self.directory, "java-kotlin", 7)) - self.report["runs"][0]["results"] = [{"ruleId": "missing"}] - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) + def test_missing_runs_tool_rules_or_results_fails(self) -> None: + """Verify missing runs tool rules or results fails.""" + variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}] + for field in ["tool", "results"]: + bad = copy.deepcopy(self.report) + del bad["runs"][0][field] + variants.append(bad) + bad = copy.deepcopy(self.report) + bad["runs"][0]["tool"]["driver"]["rules"] = [] + variants.append(bad) + for bad in variants: + with self.subTest(report=bad): + self.save(bad) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) - def test_failed_invocation_and_invalid_threshold_fail(self): - self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}] - self.save(self.report) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", 7) - with self.assertRaises(ValueError): - codeql.check(self.directory, "java-kotlin", float("nan")) + def test_high_finding_blocks_and_unknown_rule_fails(self) -> None: + """Verify high finding blocks and unknown rule fails.""" + self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}] + self.save(self.report) + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(codeql.check(self.directory, "java-kotlin", 7)) + self.report["runs"][0]["results"] = [{"ruleId": "missing"}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + + def test_failed_invocation_and_invalid_threshold_fail(self) -> None: + """Verify failed invocation and invalid threshold fail.""" + self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", float("nan")) if __name__ == "__main__": - unittest.main() + unittest.main() diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py new file mode 100644 index 0000000..71a12d9 --- /dev/null +++ b/.github/scripts/test-script-quality.py @@ -0,0 +1,238 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools.""" + +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from typing import Any, override +from unittest import mock + +sys.dont_write_bytecode = True + +ROOT = Path(__file__).resolve().parents[2] +GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n' +UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n' +GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n" +BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n" +GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n" +INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n' + + +def load(name: str) -> ModuleType: + """Load the real sibling guard for tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +guard = load("check-script-quality") + + +def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]: + """Run one real tool invocation without a shell and capture its diagnostics.""" + return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell. + + +def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]: + """Run the locked Ruff with the repository configuration on one private fixture.""" + return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent) + + +class ScopeTests(unittest.TestCase): + """Reject tracked sources that fall outside the explicit tool roots.""" + + def test_python_outside_script_root_is_rejected(self) -> None: + """Reject a tracked Python file outside the script root instead of letting it disappear.""" + for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"): + guard.python_scope([".github/scripts/check-a.py", stray]) + + def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None: + """Select only the direct script root, sorted, and reject selecting nothing.""" + self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"])) + with self.assertRaisesRegex(ValueError, "No owned Python"): + guard.python_scope(["README.md"]) + + def test_javascript_requires_tsconfig_include_scope(self) -> None: + """Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8") + self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"])) + for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"): + guard.javascript_scope(root, ["tools/c.mjs", stray]) + with self.assertRaisesRegex(ValueError, "No owned JavaScript"): + guard.javascript_scope(root, ["README.md"]) + + def test_javascript_without_tsconfig_is_rejected(self) -> None: + """Reject JavaScript in a repository that has no tsconfig.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.assertEqual([], guard.javascript_scope(root, ["README.md"])) + with self.assertRaisesRegex(ValueError, "without a tsconfig.json"): + guard.javascript_scope(root, ["tools/a.mjs"]) + + +class PyrightCountTests(unittest.TestCase): + """Reject Pyright reports that analyzed too few files or found anything.""" + + @staticmethod + def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]: + """Build the exact Pyright JSON shape consumed by the guard.""" + return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0} + + def test_nonzero_exit_fails_even_with_clean_summary(self) -> None: + """Fail a nonzero Pyright exit even when every count is clean.""" + clean = self.report(4) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 4) + + def test_count_must_match_selected_sources(self) -> None: + """Fail zero or mismatched analyzed counts even with no diagnostics.""" + guard.check_pyright_report(self.report(4), 4) + for analyzed in (0, 3, 5): + with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"): + guard.check_pyright_report(self.report(analyzed), 4) + + def test_errors_and_warnings_fail(self) -> None: + """Fail on errors and on warnings at the exact count.""" + for report in (self.report(4, errors=1), self.report(4, warnings=1)): + with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 4) + + +class RuffProbeTests(unittest.TestCase): + """Run the real locked Ruff on bounded private fixtures.""" + + def test_compliant_fixture_passes_and_violations_fail(self) -> None: + """Pass a compliant control and reject unused imports and wrong indentation.""" + with tempfile.TemporaryDirectory() as name: + probe = Path(name) / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + self.assertEqual(0, ruff(["check"], probe).returncode) + self.assertEqual(0, ruff(["format", "--check"], probe).returncode) + probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n") + unused = ruff(["check", "--output-format", "concise"], probe) + self.assertNotEqual(0, unused.returncode) + self.assertIn("F401", unused.stdout) + probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n") + indented = ruff(["format", "--check"], probe) + self.assertNotEqual(0, indented.returncode) + self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower()) + + +class PyrightProbeTests(unittest.TestCase): + """Run the real locked Pyright with the repository strict settings on private projects.""" + + @staticmethod + def project(root: Path, include: list[str] | None = None) -> None: + """Write the repository Pyright config, without its venv location, into a private project.""" + config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8")) + del config["venvPath"], config["venv"] + if include is not None: + config["include"] = include + (root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8") + + def test_compliant_control_passes_and_strict_violations_fail(self) -> None: + """Analyze the control once with no findings and reject incompatible and unannotated code.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.project(root) + scripts = root / "scripts" + scripts.mkdir() + probe = scripts / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + clean = guard.run_pyright(root, ".") + self.assertEqual(0, clean["exitCode"]) + guard.check_pyright_report(clean, 1) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 1) + probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n") + report = guard.run_pyright(root, ".") + self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]}) + with self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 1) + probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n") + strict = guard.run_pyright(root, ".") + self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]}) + + def test_missing_locked_pyright_or_node_is_rejected(self) -> None: + """Reject a missing locked Pyright entry or Node instead of silently skipping the type gate.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"): + guard.run_pyright(root, ".") + with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"): + guard.run_pyright(root, ".") + + def test_zero_analyzed_files_cannot_pass(self) -> None: + """Reject an empty include and a hidden-directory root config in the wrapper.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + scripts = root / ".github" / "scripts" + scripts.mkdir(parents=True) + (scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + for include in (["nothing/*.py"], [".github/scripts/*.py"]): + self.project(root, include) + with self.subTest(include=include), self.assertRaises(ValueError): + guard.check_pyright_report(guard.run_pyright(root, "."), 1) + + +class JavaScriptProbeTests(unittest.TestCase): + """Run the real locked ESLint and TypeScript on bounded private input.""" + + @override + def setUp(self) -> None: + """Skip only where tsconfig is absent, because the scope guard forbids JavaScript there.""" + if not (ROOT / "tsconfig.json").is_file(): + self.skipTest("No JavaScript tooling in this repository") + node = shutil.which("node") + self.assertIsNotNone(node) + self.node = str(node) + + def eslint(self, text: str) -> subprocess.CompletedProcess[str]: + """Lint text as an in-scope tool file using the repository configuration.""" + return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text) + + def test_eslint_rejects_wrong_layout(self) -> None: + """Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon.""" + good = self.eslint(GOOD_JAVASCRIPT) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + bad = self.eslint(BAD_LAYOUT_JAVASCRIPT) + self.assertNotEqual(0, bad.returncode) + rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]} + self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules) + + def test_checked_javascript_rejects_incompatible_assignment(self) -> None: + """Reject a real incompatible assignment under the repository strict checkJs settings.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8") + tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")] + (root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n") + good = run(tsc, root) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + (root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n") + bad = run(tsc, root) + self.assertNotEqual(0, bad.returncode) + self.assertIn("TS2322", bad.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-source-headers.py b/.github/scripts/test-source-headers.py index 8a04b44..20102c8 100644 --- a/.github/scripts/test-source-headers.py +++ b/.github/scripts/test-source-headers.py @@ -11,7 +11,6 @@ import unittest from pathlib import Path from typing import Any - SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py")) assert SPEC is not None and SPEC.loader is not None HEADERS = importlib.util.module_from_spec(SPEC) @@ -32,8 +31,7 @@ limitations under the License.""" def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]: """Use the production styles with a small explicit owned fixture scope.""" - return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], - "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} + return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} class HeaderTests(unittest.TestCase): @@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase): with self.subTest(name=name): HEADERS.check_notice(name, text, fixture_policy()) + def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None: + """Accept a hyphen or en dash between years and reject any other separator.""" + for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)): + text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT" + with self.subTest(separator=separator): + if accepted: + HEADERS.check_notice("src/a.py", text, fixture_policy()) + else: + with self.assertRaises(ValueError): + HEADERS.check_notice("src/a.py", text, fixture_policy()) + def test_required_first_lines_and_bom(self) -> None: """Accept shebangs, PowerShell requirements and XML declarations first.""" for name, prefix, body in ( ("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")), ("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")), - ("src/a.xml", "\n", ""), + ("src/a.xml", '\n', ""), ("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"), ): with self.subTest(name=name): @@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase): for name, text in ( ("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"), ("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"), - ("src/a.xml", ""), + ("src/a.xml", "'), ): with self.subTest(name=name), self.assertRaises(ValueError): HEADERS.check_notice(name, text, fixture_policy()) def test_code_strings_and_late_comments_do_not_count(self) -> None: """Reject docstrings and comments after executable declarations.""" - for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"", - "\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")): + for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.py", text, fixture_policy()) def test_wrong_syntax_and_unterminated_comments_fail(self) -> None: """Notice text alone is insufficient without a complete language comment.""" - for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"): + for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.cs", text, fixture_policy()) with self.assertRaises(ValueError): @@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase): def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None: """Do not conflate repository licenses or accept conflicting identifiers.""" - for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), - MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]): + for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]): with self.subTest(text=text), self.assertRaises(ValueError): HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy()) @@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase): def test_java_requires_full_apache_block(self) -> None: """An abbreviated SPDX-only Java notice cannot replace scoped convention.""" policy = fixture_policy("Kingsrook, LLC", "Apache-2.0") - for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", - APACHE.replace("limitations under the License.", "")): + for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")): with self.subTest(notice=notice), self.assertRaises(ValueError): HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy) @@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase): """New scripts enter the guard; data, generated and upstream files do not.""" self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY)) self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY)) - for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", - "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", - "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"): + for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"): with self.subTest(name=name): self.assertFalse(HEADERS.selected(name, POLICY)) self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"])) @@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase): """A tracked new source fails while untracked generated output is ignored.""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) - subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory. root.joinpath("src").mkdir() source = root / "src/a.py" source.write_text("print(1)", encoding="utf-8") - subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) + subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command. with self.assertRaisesRegex(ValueError, "src/a.py"): HEADERS.check_repository(root, fixture_policy()) source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a41bce..d1dec72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,30 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589 + ref: a26c18c90c7d6102bbe26eb50cbb90fcd1bd46e3 path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: '0.13.6' + enable-cache: false + - name: Restore locked Python tools + run: uv sync --locked + - name: Verify script quality gates and rejection paths + run: | + uv run --locked --no-sync python .github/scripts/test-script-quality.py + uv run --locked --no-sync python .github/scripts/check-script-quality.py - name: Verify owned source notices and rejection paths run: | python3 .github/scripts/test-source-headers.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8da4dcc..626350e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -174,7 +174,7 @@ jobs: persist-credentials: false - name: Set up uv - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: enable-cache: false diff --git a/.gitignore b/.gitignore index e47bfdc..3f84bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ dist/ *.iml .DS_Store Thumbs.db + +# Local script-quality environments and tool caches. +.venv/ +node_modules/ +.ruff_cache/ +__pycache__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 469ae87..ecc0deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,45 @@ ## Unreleased (0.4.0-SNAPSHOT) -Printing extension **2.1.0** (additive; the agent contract is unchanged). +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. +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89a49f8..044c510 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,3 +9,14 @@ Changes belong in a pull request with a concise behavior description and actual Keep the QBit compatible with QQQ 4.1+ and resolve runtime services through the current QQQ instance. Preserve tenant/device isolation and the distinction between local exposure (`available`) and server approval (`enabled`). Application callers must authorize destinations before submitting or emitting an effect. Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets and publishes GitHub prereleases for hyphenated versions. Maven Central publication, deployment and physical printer tests are separate operations; this workflow does not perform them. + +## Script quality + +Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint (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. diff --git a/README.md b/README.md index db3e486..e72ba0a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ JSONObject job = printing.submit(new JSONObject() String printJobId = job.getString("jobId"); ``` -For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. +For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`). The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics. diff --git a/docs/OPERATOR-CATALOG.md b/docs/OPERATOR-CATALOG.md new file mode 100644 index 0000000..3edd1cf --- /dev/null +++ b/docs/OPERATOR-CATALOG.md @@ -0,0 +1,67 @@ +# 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. diff --git a/docs/RETAINED-PRINT-FACADE.md b/docs/RETAINED-PRINT-FACADE.md new file mode 100644 index 0000000..68b36af --- /dev/null +++ b/docs/RETAINED-PRINT-FACADE.md @@ -0,0 +1,114 @@ +# Retained print acceptance (additive candidate) + +This optional Java integration preserves the existing native generation print +process and the existing printing queue. It adds no device wire fields or REST +routes. It is disabled until the host supplies +`PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator` +(or administrator) authority and native tenant/device/printer record checks +remain mandatory. Submission also requires the shared `printingSubmit` process +permission. This grants no device management or generic job-history access. + +## Public contract + +`RetainedPrintFacade.Request(requestId, sourceLocator, printerRecordId, title, +optionsJson)` contains no actor or asserted source binding. Request IDs use +`[A-Za-z0-9._:-]{1,128}`, including owner namespaces such as `echo:`. +`submit` derives the signed-in native actor and returns an immutable +`Acceptance`; it includes the exact source, job/event identities, tenant, +initiating actor, destination, title and normalized options JSON, including any +`mediaProfile` ID/revision. `find` authorizes the same complete request and +returns that acceptance, null for observed absence, or a conflict for an +unresolved upload reservation. Reuse the same request identity after a lost +response. Never convert an unavailable lookup into absence or create another +intent automatically. + +The host-configured `RetainedPrintSourceAuthority.resolve(locator, requestId)` +must authorize the current native actor, source association and currently +eligible immutable version. It returns `SourceReference(owner, opaqueReference, +sha256, sizeBytes, mediaType)`. `opaqueReference` is at most 4000 Java +characters, stable and canonical under that owner's rules; printing does not +parse owner-specific fields. The optional Record Files adapter must encode its +complete version/source/generation-request/initiating-actor/tenant tuple and +compare generation's committed receipt. Accepted MIME types are +`application/pdf` and `application/vnd.zebra-zpl`; the current configured +PDF/ZPL limits apply before the byte read, within the existing integer-length +storage ceiling. `read(reference, requestId)` must enforce the same current +permissions and return only those exact bytes. Hash/length verification remains +in printing's existing submission path. Do not supply arbitrary source +authorities per request or reconstruct a user session. + +Source admission remains the owner's policy. An explicitly configured published +template may qualify generated output without a per-label release dialog; +external-upload quarantine is unchanged. Printing performs no automatic release +and creates no human-release audit. `resolve` must be read-only and must not +invoke a generation reconciliation operation that can generate or dispatch work. + +Source/destination checks surround owner callbacks and precede acceptance. +Submission refuses an ambient QQQ transaction: queue acceptance is independently +committed and cannot be rolled back by a caller's transaction. Submission also +enforces the native job-table credential and channel mutation ceilings; lookup +uses the corresponding read ceilings without granting generic table access. +`find` calls no byte reader, submission or event writer, and its option +normalization cannot initialize settings. It reauthorizes the source and +destination records, then compares the exact accepted intent; a later exposure, +media inventory or dispatch-limit change does not reinterpret that historical +intent. A later permission loss may deny returning a receipt whose job already +committed; it never authorizes a replacement job. + +## Persistence and replay + +Existing `printing_job` gains nullable `retained_actor_id`, +`retained_binding_json`, and `accepted_event_id`. Existing `printing_event` +gains nullable `event_origin`. `initialize` applies additive upgrades; old +jobs/events remain unbound/unclassified. None of these internal fields are added +to the device `JOB_COLUMNS` DTO or native table metadata. + +Submission and exact lookup both use the existing tenant admission lock, so an +in-flight inline acceptance cannot be mistaken for absence. The unique +`(tenant_id, idempotency_key)` serialize submissions. Retained keys use +`retained:` plus the request ID. Ordinary/bound collisions refuse. Complete +normalized intent includes actor, owner reference, content hash/length/media, +destination, title, options and tenant. The existing storage reservation/orphan +path is reused; it may commit an upload reservation before writing object +storage. The final job, retained binding, and server acceptance event commit +together. Failed acceptance-event insertion rolls back the job. Rollback or +orphan-cleanup failures are suppressed onto the original failure rather than +replacing it. Existing initialization widens legacy 300-character titles to +1024; the bound title preserves exact whitespace and Unicode and is compared on +replay. Acceptance-event JSON has `type=ACCEPTED`, `origin=SERVER_ACCEPTANCE`, +`status=QUEUED`; it does not claim device receipt or physical output. + +This is atomic acceptance binding inside printing, not a distributed transaction +over the retained owner's authorization database. Deployment still owns durable +database/storage settings, bounded provider calls and current session policy. + +## Committed observations + +`observation(requestId, eventId)` reads only an exact committed event joined to +the accepted job. New ordinary device events are stamped `DEVICE_OBSERVATION`; +pre-upgrade unclassified events and server acceptance events cannot be returned +as device evidence. The current native operator must have current device/printer +row authority and satisfy the job credential/channel read ceiling. Before +returning any source metadata, printing calls the configured owner's +`authorizeObservation(source, requestId)` under the current reporter session, +then rechecks the current ceiling and destination authority. Its default +implementation refuses; adapters must explicitly implement their current +source-observation policy. This callback does not grant content access or +impersonate the initiating actor. This is an operator-authorized observation +lookup, not device enrollment or permission to forge reporter identity. The +optional RF authority adapter must preserve initiating actor separately from +current reporting actor and map only supported stored states. Neither `SPOOLED` +nor `SPOOLER_COMPLETED` proves physical output. + +## Verification scope + +The tests use real H2 transactions and native QQQ row authorization with +synthetic printers. They never contact a device or emit physical output. +Coverage includes commit-then-throw acknowledgment loss, concurrent exact +replay, held-admission-lock lookup, mutation ceilings, owner observation +refusal, callback revocation, exact content hash/length, configured document +bounds, title upgrade/replay, read-only historical lookup and original-error +preservation when rollback fails. Qualification of this adopted source is +recorded in the session notes; the older peer candidate's test result is not +evidence for this revision. Cross-QBit integration remains a separate gate. No +release, install, shared-cache substitution or runtime migration has occurred. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 5f6a5af..7925632 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,58 @@ # 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 diff --git a/docs/TODO.md b/docs/TODO.md index 958bdf9..9ff71b8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,29 @@ # 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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..452783d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "pyright": "1.1.414" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pyright": { + "version": "1.1.414", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.414.tgz", + "integrity": "sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww==", + "dev": true, + "license": "MIT", + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6542f49 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "private": true, + "description": "Development-only script quality tooling for QBit Printing; no runtime dependency.", + "license": "Apache-2.0", + "engines": { + "node": ">=24" + }, + "devDependencies": { + "pyright": "1.1.414" + }, + "packageManager": "npm@11.19.1" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f63273 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "qbit-printing-script-quality" +version = "0.0.0" +requires-python = ">=3.13" + +[dependency-groups] +dev = ["ruff==0.17.3"] + +[tool.uv] +package = false diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..286fa55 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,56 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 +# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152. +# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root +# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check` +# run in CI and pre-commit with zero findings allowed. +target-version = "py313" +include = [".github/scripts/*.py"] +extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"] +# Kingsrook: no practical line-length limit (ruff's maximum is 320). +line-length = 320 +# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's +# "multiple of four" rules (E111/E114) are therefore not selected. +indent-width = 3 + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +docstring-code-format = true + +[lint] +select = [ + "F", # pyflakes + "E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent + "I", # isort: no wildcard imports, ordered + "N", # pep8-naming + "UP", # pyupgrade + "B", # bugbear + "S", # bandit security + "BLE", # blind except + "T20", # print statements: use logging (Kingsrook logging rule) + "ERA", # commented-out code (Kingsrook: no zombie code) + "FIX", # FIXME/TODO must be resolved or tracked + "TD", # TODO format: author and issue link + "PTH", # pathlib over os.path + "RET", "SIM", "C4", "PIE", "PL", "RUF", + "ANN", # type annotations on every function + "D", # docstrings on modules, classes, functions + "LOG", "G", # logging hygiene +] +ignore = [ + "D203", # conflicts with D211 + "D213", # conflicts with D212 + "TD003", # issue link is enforced by review, not regex +] + +[lint.per-file-ignores] +".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging. +".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values. + +[lint.pydocstyle] +convention = "pep257" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java index e84015b..ebf594c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java @@ -48,11 +48,19 @@ final class PrintingApiSchemas add(schemas, "EnrollmentRequest", fields("enrollmentCode", text(128), "deviceName", text(1024), "agentVersion", text(100)), "enrollmentCode", "deviceName", "agentVersion"); add(schemas, "Enrollment", fields("protocolVersion", number(), "deviceId", id(), "deviceToken", text(128).withDescription("Private device proof, returned once; never log."), "pollIntervalSeconds", number()), "protocolVersion", "deviceId", "deviceToken", "pollIntervalSeconds"); add(schemas, "Profile", fields("name", text(512).withNullable(true), "description", text(4096).withNullable(true), "location", text(512).withNullable(true))); + add(schemas, "MediaSelection", fields("id", id(), "revision", text(64)), "id", "revision"); + add(schemas, "MediaProfile", fields("id", id(), "revision", text(64), "name", text(512), "widthMicrons", number(), "heightMicrons", number(), "dpiX", number(), "dpiY", number(), + "source", enumeration("WINDOWS_QUEUE_DEFAULT"), "applyMode", enumeration("DRIVER_DEFAULT")), "id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode"); + add(schemas, "OperatorPrinter", fields("printerRowId", id(), "deviceId", id(), "printerId", id(), "name", text(1024), "deviceName", text(1024), "description", text(4096), "location", text(512), "status", text(40), "formats", array(enumeration("PDF", "ZPL")), "mediaProfiles", array(ref("MediaProfile"))), + "printerRowId", "deviceId", "printerId", "name", "deviceName", "status", "formats", "mediaProfiles"); + add(schemas, "OperatorCatalog", fields("printers", array(ref("OperatorPrinter")), "nextCursor", text(200).withNullable(true)), "printers", "nextCursor"); + add(schemas, "OperatorSubmit", fields("requestId", text(128), "printerRowId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentBase64", text(Integer.MAX_VALUE), "options", ref("Options")), + "requestId", "printerRowId", "title", "format", "contentBase64"); add(schemas, "HeartbeatRequest", fields("agentVersion", text(100), "profile", ref("Profile")), "agentVersion"); add(schemas, "Heartbeat", fields("serverTime", time()), "serverTime"); - add(schemas, "Printer", fields("printerId", id(), "name", text(1024), "driverName", text(1024), "portName", text(1024), "status", enumeration("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN"), "formats", array(enumeration("PDF", "ZPL")), "displayName", text(512), "description", text(4096), "location", text(512)), "printerId", "name", "formats"); + add(schemas, "Printer", fields("printerId", id(), "name", text(1024), "driverName", text(1024), "portName", text(1024), "status", enumeration("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN"), "formats", array(enumeration("PDF", "ZPL")), "mediaProfiles", array(ref("MediaProfile")), "displayName", text(512), "description", text(4096), "location", text(512)), "printerId", "name", "formats"); add(schemas, "InventoryRequest", fields("printers", array(ref("Printer"))), "printers"); - add(schemas, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "paperWidthMm", new Schema().withType(Type.NUMBER), "paperHeightMm", new Schema().withType(Type.NUMBER))); + add(schemas, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "mediaProfile", ref("MediaSelection"), "paperWidthMm", new Schema().withType(Type.NUMBER), "paperHeightMm", new Schema().withType(Type.NUMBER))); add(schemas, "Job", fields("jobId", id(), "deviceId", id(), "printerId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentSha256", text(64), "contentLength", number(), "options", ref("Options"), "createdAt", time(), "status", enumeration("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN"), "message", text(2048), "spoolerJobId", id()), "jobId", "deviceId", "printerId", "title", "format", "contentSha256", "contentLength", "options", "createdAt", "status"); add(schemas, "Claim", fields("job", ref("Job").withNullable(true)), "job"); add(schemas, "Jobs", fields("jobs", array(ref("Job"))), "jobs"); @@ -60,7 +68,7 @@ final class PrintingApiSchemas add(schemas, "TestRequest", fields("requestId", id(), "printerId", id(), "format", enumeration("PDF", "ZPL"), "testProfile", text(40)), "requestId", "printerId", "format"); add(schemas, "RetryRequest", fields("requestId", id(), "confirmDuplicateRisk", bool()), "requestId"); add(schemas, "DesiredState", fields("revision", number(), "dispatchPaused", bool(), "connectionState", enumeration("CONNECTED", "DISCONNECTED")), "revision", "dispatchPaused", "connectionState"); - add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision"); + add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision"); add(schemas, "Command", fields("commandId", id(), "requestId", id(), "action", enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL"), "createdAt", time(), "expiresAt", time(), "jobId", id()), "commandId", "requestId", "action", "createdAt", "expiresAt"); add(schemas, "ControlPoll", fields("controlVersion", number(), "serverTime", time(), "pollIntervalSeconds", number(), "desiredState", ref("DesiredState"), "commands", array(ref("Command"))), "controlVersion", "serverTime", "pollIntervalSeconds", "desiredState", "commands"); add(schemas, "ReceiptRequest", fields("receiptId", id(), "outcome", enumeration("INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "completedAt", time(), "message", text(1024)), "receiptId", "outcome", "completedAt"); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java new file mode 100644 index 0000000..dde6b33 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java @@ -0,0 +1,149 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; + + +/******************************************************************************* + ** The header and footer every printed page carries, whatever the document: + ** + ** · · + ** + ** ... + ** <footer note: a grid's filter and sort> + ** <footer: "qqq ui 1.0.0 · server"> Page n of m + ** + ** Clients send the fields as process values (headerApp, headerTitle, + ** headerServer, headerUser, headerPrintedAt, footerLeft, footerNote) so the + ** text matches what the user saw (their date format, the app's name, the + ** breadcrumb). Missing fields fall back to the instance's branding name, the + ** session user, the current instant and "QQQ printing". + *******************************************************************************/ +public record PrintingChrome(String app, String title, String server, String user, String printedAt, String footerLeft, String footerNote) +{ + /*************************************************************************** + ** The fields from process values, with server-side defaults. + ***************************************************************************/ + public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle) + { + return (new PrintingChrome( + value(input, "headerApp", defaultApp()), + value(input, "headerTitle", fallbackTitle), + value(input, "headerServer", ""), + value(input, "headerUser", defaultUser()), + value(input, "headerPrintedAt", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()), + value(input, "footerLeft", "QQQ printing"), + value(input, "footerNote", ""))); + } + + + + /*************************************************************************** + ** Defaults only (documents rendered outside a process). + ***************************************************************************/ + public static PrintingChrome defaults(String title) + { + return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", "")); + } + + + + /*************************************************************************** + ** The same chrome with a footer note (a grid's filter and sort). + ***************************************************************************/ + public PrintingChrome withFooterNote(String note) + { + return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote)); + } + + + + /*************************************************************************** + ** The same chrome with this title when it has none. + ***************************************************************************/ + public PrintingChrome withTitleIfBlank(String fallback) + { + return (title != null && !title.isBlank() ? this : new PrintingChrome(app, fallback, server, user, printedAt, footerLeft, footerNote)); + } + + + + /*************************************************************************** + ** The right side of the first header line. + ***************************************************************************/ + String headerRight() + { + StringBuilder out = new StringBuilder(); + for(String part : new String[] {server, user, printedAt}) + { + if(part != null && !part.isBlank()) + { + out.append(out.isEmpty() ? "" : " · ").append(part); + } + } + return (out.toString()); + } + + + + /*************************************************************************** + ** A string value, or the default when absent or blank (bounded length). + ***************************************************************************/ + private static String value(RunBackendStepInput input, String name, String fallback) + { + String value = input == null ? null : input.getValueString(name); + String chosen = value == null || value.isBlank() ? fallback : value; + return (chosen == null ? "" : PrintingRecordDocuments.truncate(chosen.strip(), 300)); + } + + + + /*************************************************************************** + ** The instance's branding name, else "QQQ". + ***************************************************************************/ + private static String defaultApp() + { + try + { + var branding = QContext.getQInstance() == null ? null : QContext.getQInstance().getBranding(); + return (branding != null && branding.getAppName() != null ? branding.getAppName() : "QQQ"); + } + catch(RuntimeException exception) + { + return ("QQQ"); + } + } + + + + /*************************************************************************** + ** The session user's name, else blank. + ***************************************************************************/ + private static String defaultUser() + { + return (QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getFullName() == null ? "" : QContext.getQSession().getUser().getFullName()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java index beb3cf2..de8ae26 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java @@ -266,8 +266,8 @@ final class PrintingDeviceControls } String version = text(request, "agentVersion", 100); long applied = integer(request, "lastAppliedRevision"); - if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 2 - || capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length()) + if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 3 + || capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length()) { throw new PrintingException(400, "Invalid control capabilities"); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java index fd5723a..caea1ec 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java @@ -54,7 +54,7 @@ final class PrintingLayout implements AutoCloseable private float y; private List<String> repeatHeader = List.of(); private int repeatSize = 8; - private String footer = ""; + private PrintingChrome chrome; private float leading = 1.35f; @@ -65,6 +65,17 @@ final class PrintingLayout implements AutoCloseable ***************************************************************************/ PrintingLayout(String pageSize, boolean landscape, String title, String subtitle) throws QException { + this(pageSize, landscape, title, subtitle, null); + } + + + + /*************************************************************************** + ** With the app's header and footer on every page (defaults when null). + ***************************************************************************/ + PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, PrintingChrome chrome) throws QException + { + this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome; try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE)) { if(stream == null) @@ -117,11 +128,11 @@ final class PrintingLayout implements AutoCloseable /*************************************************************************** - ** Text at the bottom left of every page (who printed it and when). + ** A note above the footer of every page (a grid's filter and sort). ***************************************************************************/ void footer(String text) { - footer = text == null ? "" : text; + chrome = chrome.withFooterNote(text); } @@ -143,7 +154,7 @@ final class PrintingLayout implements AutoCloseable void line(String text, float fontSize) throws IOException { float step = fontSize * leading; - if(y - step < MARGIN + 14) + if(y - step < bottom()) { newPage(); for(String header : repeatHeader) @@ -161,7 +172,7 @@ final class PrintingLayout implements AutoCloseable ***************************************************************************/ void keep(int lines, int fontSize) throws IOException { - if(y - lines * fontSize * 1.35f < MARGIN + 14) + if(y - lines * fontSize * leading < bottom()) { newPage(); } @@ -208,19 +219,17 @@ final class PrintingLayout implements AutoCloseable try(PDPageContentStream footer = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { String text = "Page " + (i + 1) + " of " + total; - footer.beginText(); - footer.setFont(font, 8); - footer.newLineAtOffset(size.getWidth() - MARGIN - text.length() * 4.8f, MARGIN - 14); - footer.showText(text); - footer.endText(); - if(!this.footer.isEmpty()) + float base = MARGIN - 14; + footer.setLineWidth(0.5f); + footer.moveTo(MARGIN, base + (hasNote() ? 20 : 10)); + footer.lineTo(size.getWidth() - MARGIN, base + (hasNote() ? 20 : 10)); + footer.stroke(); + if(hasNote()) { - footer.beginText(); - footer.setFont(font, 8); - footer.newLineAtOffset(MARGIN, MARGIN - 14); - footer.showText(safe(PrintingRecordDocuments.truncate(this.footer, Math.max(10, columns(8) - text.length() - 2)))); - footer.endText(); + at(footer, MARGIN, base + 10, PrintingRecordDocuments.truncate(chrome.footerNote(), columns(7.5f)), 7.5f); } + at(footer, size.getWidth() - MARGIN - text.length() * 4.8f, base, text, 8); + at(footer, MARGIN, base, PrintingRecordDocuments.truncate(chrome.footerLeft(), Math.max(10, columns(8) - text.length() - 2)), 8); } } ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -273,7 +282,8 @@ final class PrintingLayout implements AutoCloseable PDPage page = new PDPage(size); document.addPage(page); content = new PDPageContentStream(document, page); - y = size.getHeight() - MARGIN; + y = size.getHeight() - MARGIN + 14; + header(); if(document.getNumberOfPages() > 1) { draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12); @@ -283,6 +293,68 @@ final class PrintingLayout implements AutoCloseable + /*************************************************************************** + ** The app's header band: the app and server/user/time, then the title or + ** breadcrumb, then a rule. + ***************************************************************************/ + private void header() throws IOException + { + String right = chrome.headerRight(); + int rightChars = Math.min(right.length(), columns(7.5f) / 2 + 10); + String rightText = PrintingRecordDocuments.truncate(right, rightChars); + y -= 9; + at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9); + at(content, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f); + if(chrome.title() != null && !chrome.title().isBlank()) + { + y -= 10; + at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f); + } + y -= 4; + content.setLineWidth(0.5f); + content.moveTo(MARGIN, y); + content.lineTo(size.getWidth() - MARGIN, y); + content.stroke(); + content.setLineWidth(1f); + y -= 6; + } + + + + /*************************************************************************** + ** Whether pages carry a footer note. + ***************************************************************************/ + private boolean hasNote() + { + return (chrome.footerNote() != null && !chrome.footerNote().isBlank()); + } + + + + /*************************************************************************** + ** The lowest baseline body text may use, above the footer band. + ***************************************************************************/ + private float bottom() + { + return (MARGIN + (hasNote() ? 22 : 12)); + } + + + + /*************************************************************************** + ** Text at a position on a content stream. + ***************************************************************************/ + private void at(PDPageContentStream stream, float x, float baseline, String text, float fontSize) throws IOException + { + stream.beginText(); + stream.setFont(font, fontSize); + stream.newLineAtOffset(x, baseline); + stream.showText(safe(text)); + stream.endText(); + } + + + /*************************************************************************** ** Draw one line at the current position. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java index 82bd24e..358d38b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java @@ -326,6 +326,108 @@ public final class PrintingListProcesses + /*************************************************************************** + ** A rendered document: its title, the PDF, and a note (rows left out). + ***************************************************************************/ + public record Rendered(String title, byte[] bytes, String message) + { + } + + + + /*************************************************************************** + ** Render a table list (list true) or a text page from the process values. + ***************************************************************************/ + static Rendered render(RunBackendStepInput input, boolean list) throws QException + { + String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT); + if(!list) + { + String title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title"); + String subtitle = input.getValueString("subtitle"); + boolean landscape = "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation")); + PrintingChrome chrome = PrintingChrome.from(input, title); + String sections = input.getValueString("sections"); + if(sections != null && !sections.isBlank()) + { + return (new Rendered(title, PrintingTextDocuments.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null)); + } + return (new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), pageSize, landscape, chrome), null)); + } + QTableMetaData table = table(input); + List<QFieldMetaData> columns = columns(table, input.getValueString("columns")); + QQueryFilter filter = filter(input); + boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope")); + List<QRecord> records = read(table, filter, all); + List<List<String>> rows = new ArrayList<>(); + for(QRecord record : records) + { + List<String> row = new ArrayList<>(); + columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field))); + rows.add(row); + } + String title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel(); + Integer total = input.getValueInteger("printingRowCount"); + if(total == null) + { + CountInput count = new CountInput(); + count.setTableName(table.getName()); + count.setFilter(filter.clone().withSkip(null).withLimit(null)); + count.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + total = new CountAction().execute(count).getCount(); + } + int skip = filter.getSkip() == null ? 0 : filter.getSkip(); + String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows") + : "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : ""); + String described = input.getValueString("filterDescription"); + String sort = sortText(table, filter); + String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort); + String note = (described != null && !described.isBlank() ? "Filter: " + described : "No filter") + (sort.isEmpty() ? "" : " - sort: " + sort); + byte[] bytes = PrintingTableDocuments.pdf(title, subtitle, note, + columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input), + PrintingChrome.from(input, table.getLabel() == null ? table.getName() : table.getLabel())); + String message = all && total != null && total > records.size() + ? "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest." : null; + return (new Rendered(title, bytes, message)); + } + + + + /*************************************************************************** + ** The "sections" value: a JSON array of {title, lines: [..]} (at most 500 + ** sections), as clients send a screen's content. + ***************************************************************************/ + static List<PrintingTextDocuments.Section> sections(String json) throws QException + { + try + { + org.json.JSONArray array = new org.json.JSONArray(json); + if(array.length() > 500) + { + throw new QException("Too many sections to print (at most 500)"); + } + List<PrintingTextDocuments.Section> out = new ArrayList<>(); + for(int i = 0; i < array.length(); i++) + { + org.json.JSONObject section = array.getJSONObject(i); + List<String> lines = new ArrayList<>(); + org.json.JSONArray given = section.optJSONArray("lines"); + for(int j = 0; given != null && j < given.length(); j++) + { + lines.add(given.optString(j, "")); + } + out.add(new PrintingTextDocuments.Section(section.optString("title", ""), lines)); + } + return (out); + } + catch(org.json.JSONException exception) + { + throw new QException("The sections are not valid: " + exception.getMessage(), exception); + } + } + + + /*************************************************************************** ** Request identity, and for a table list the number of rows it would print. ***************************************************************************/ @@ -378,45 +480,14 @@ public final class PrintingListProcesses { throw new QException("Restart the print to obtain a safe request identity"); } - String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT); - String title; - byte[] bytes; - if(TABLE_LIST.equals(input.getProcessName())) + Rendered rendered = render(input, TABLE_LIST.equals(input.getProcessName())); + if(rendered.message() != null) { - QTableMetaData table = table(input); - List<QFieldMetaData> columns = columns(table, input.getValueString("columns")); - QQueryFilter filter = filter(input); - boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope")); - List<QRecord> records = read(table, filter, all); - List<List<String>> rows = new ArrayList<>(); - for(QRecord record : records) - { - List<String> row = new ArrayList<>(); - columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field))); - rows.add(row); - } - title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel(); - Integer total = input.getValueInteger("printingRowCount"); - int skip = filter.getSkip() == null ? 0 : filter.getSkip(); - String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows") - : "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : ""); - String described = input.getValueString("filterDescription"); - String sort = sortText(table, filter); - String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort); - bytes = PrintingTableDocuments.pdf(title, subtitle, (table.getLabel() == null ? table.getName() : table.getLabel()) + " - " + PrintingRecordProcesses.printedBy(), - columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input)); - if(all && total != null && total > records.size()) - { - output.addValue("printingTruncated", true); - output.addValue("printingMessage", "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest."); - } - } - else - { - title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title"); - String subtitle = input.getValueString("subtitle"); - bytes = PrintingTextDocuments.pdf(title, (subtitle == null || subtitle.isBlank() ? "" : subtitle + " - ") + PrintingRecordProcesses.printedBy(), input.getValueString("text"), pageSize, "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation"))); + output.addValue("printingTruncated", true); + output.addValue("printingMessage", rendered.message()); } + String title = rendered.title(); + byte[] bytes = rendered.bytes(); String job = PrintingRecordProcesses.queue(printer, title, bytes, copies, "list-print:" + request + ":" + input.getProcessName() + ":" + printer.getValueString("id")); output.addValue(PrintingRecordProcesses.JOB_IDS, job); output.addValue("result", "Queued print job " + job + ". Check Print jobs for its status." + (output.getValue("printingMessage") != null ? " " + output.getValue("printingMessage") : "")); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java b/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java new file mode 100644 index 0000000..bb99bbe --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java @@ -0,0 +1,162 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.util.HashSet; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Immutable reported Windows queue defaults; these never change printer stock. + *******************************************************************************/ +public record PrintingMediaProfile(String id, String revision, String name, int widthMicrons, int heightMicrons, int dpiX, int dpiY) +{ + /*************************************************************************** + ** Validate the bounded wire record without coercion or guessed dimensions. + ***************************************************************************/ + public static PrintingMediaProfile parse(JSONObject value) + { + if(!value.keySet().containsAll(Set.of("id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode")) + || !"WINDOWS_QUEUE_DEFAULT".equals(value.opt("source")) || !"DRIVER_DEFAULT".equals(value.opt("applyMode"))) + { + throw new PrintingException(400, "Invalid reported media profile"); + } + String id = text(value, "id", 128); + String revision = text(value, "revision", 64); + if(!id.matches("[A-Za-z0-9._-]+") || !revision.matches("[0-9a-f]{64}")) + { + throw new PrintingException(400, "Invalid media profile identity"); + } + return new PrintingMediaProfile(id, revision, text(value, "name", 512), integer(value, "widthMicrons", 2000000), + integer(value, "heightMicrons", 2000000), integer(value, "dpiX", 9600), integer(value, "dpiY", 9600)); + } + + + /*************************************************************************** + ** Canonical public facts are separate from the immutable job selection. + ***************************************************************************/ + public JSONObject json() + { + return new JSONObject().put("id", id).put("revision", revision).put("name", name).put("widthMicrons", widthMicrons) + .put("heightMicrons", heightMicrons).put("dpiX", dpiX).put("dpiY", dpiY) + .put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT"); + } + + + /*************************************************************************** + ** Missing inventory facts mean no known stock, including older clients. + ***************************************************************************/ + static JSONArray inventory(JSONObject printer) + { + JSONArray values = printer.has("mediaProfiles") ? printer.optJSONArray("mediaProfiles") : new JSONArray(); + if(values == null || values.length() > 32) + { + throw new PrintingException(400, "Invalid media profile list"); + } + Set<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(); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java new file mode 100644 index 0000000..7c827e1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java @@ -0,0 +1,395 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Current-user catalog and submission boundary shared by HTTP and host processes. + *******************************************************************************/ +public final class PrintingOperatorAuthorization +{ + /*************************************************************************** + ** No cached user authority or alternate queue. + ***************************************************************************/ + private PrintingOperatorAuthorization() + { + } + + + /*************************************************************************** + ** Resolve the authenticated actor and current tenant before every operation. + ***************************************************************************/ + public static String principal() throws QException + { + if(QContext.getQSession() == null || QContext.getQSession().getUser() == null + || QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank()) + { + throw new QAuthenticationException("Sign in to continue"); + } + new PrintingOperatorPermission().checkPermissionsThrowing(new RunBackendStepInput(), null); + PrintingProcesses.currentService(); + return QContext.getQSession().getUser().getIdReference(); + } + + + /*************************************************************************** + ** Native table and record checks apply even inside another backend process. + ***************************************************************************/ + private static QRecord record(String table, String id) throws QException + { + CallContext caller = capture(); + QueryInput scoped = new QueryInput().withTableName(table).withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)) + .withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId())).withLimit(1)); + scoped.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + if(new QueryAction().execute(scoped).getRecords().isEmpty()) + { + throw new QPermissionDeniedException("Print destination is not accessible"); + } + caller.check(); + GetInput input = new GetInput(); + input.setTableName(table); + input.setPrimaryKey(id); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord row = new GetAction().execute(input).getRecord(); + PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ); + caller.check(); + if(row == null || !id.equals(row.getValueString("id"))) + { + throw new QPermissionDeniedException("Print destination is not accessible"); + } + return row; + } + + + /*************************************************************************** + ** Authorize the exact native printer row and its enabled device each time. + ** Callers use the returned wire printer identity only for this operation. + ***************************************************************************/ + public static QRecord destination(String printerRowId) throws QException + { + CallContext caller = capture(); + QRecord printer = record(PrintingTables.PRINTER, printerRowId); + QRecord device = record(PrintingTables.DEVICE, printer.getValueString("device_id")); + if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available")) + || !Integer.valueOf(1).equals(device.getValueInteger("enabled"))) + { + throw new QPermissionDeniedException("Print destination is not approved and exposed"); + } + requireDestinationRead(); + caller.check(); + return printer; + } + + + /*************************************************************************** + ** Shared retained-source boundary also requires the exact supported format. + ***************************************************************************/ + public static QRecord printer(String printerRowId, String format) throws QException + { + QRecord printer = destination(printerRowId); + if(!Set.of("PDF", "ZPL").contains(format) || !new JSONArray(printer.getValueString("formats")).toList().contains(format)) + { + throw new PrintingException(400, "Printer does not support this document format"); + } + return printer; + } + + + /*************************************************************************** + ** Reconciliation still checks current row authority after exposure changes. + ** The caller must first establish its own accepted request/source binding. + ***************************************************************************/ + public static QRecord authorizeDestinationHistory(String printerRowId) throws QException + { + CallContext caller = capture(); + QRecord printer = record(PrintingTables.PRINTER, printerRowId); + record(PrintingTables.DEVICE, printer.getValueString("device_id")); + requireDestinationRead(); + caller.check(); + return printer; + } + + + /*************************************************************************** + ** Page through authorized rows; hidden parent devices never leak through joins. + ***************************************************************************/ + public static JSONObject catalog(int limit, String after) throws QException + { + CallContext caller = capture(); + if(limit < 1 || limit > 200 || (after != null && !after.matches("[A-Za-z0-9._-]{1,200}"))) + { + throw new PrintingException(400, "Invalid catalog page"); + } + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId())) + .withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1); + if(after != null) + { + filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after)); + } + QueryInput input = new QueryInput().withTableName(PrintingTables.PRINTER).withFilter(filter); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List<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"); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java new file mode 100644 index 0000000..bb0eebf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java @@ -0,0 +1,56 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; + + +/******************************************************************************* + ** Submit permission grants no device management or general job history access. + *******************************************************************************/ +public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPermissionChecker +{ + /*************************************************************************** + ** Expose the role to standard host permission assignment. + ***************************************************************************/ + public PrintingOperatorPermission() + { + withPermissionName("printing_operator"); + } + + + /*************************************************************************** + ** Administrators retain submission access; operators receive no other role. + ***************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin"))) + { + throw new QPermissionDeniedException("Printing operator permission is required"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java new file mode 100644 index 0000000..fd26da6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java @@ -0,0 +1,95 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import org.json.JSONObject; + + +/******************************************************************************* + ** Explicit operator backend process for host-generated document submissions. + *******************************************************************************/ +public class PrintingOperatorProcesses implements BackendStep +{ + public static final String SUBMIT = "printingSubmit"; + + + /*************************************************************************** + ** Host workflows call this typed input process; it grants no management role. + ***************************************************************************/ + public static void register(QInstance instance) + { + instance.addProcess(new QProcessMetaData().withName(SUBMIT).withLabel("Submit a print document") + .withIsHidden(true) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withCustomPermissionChecker(new QCodeReference(PrintingOperatorPermission.class))) + .withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(PrintingOperatorProcesses.class)) + .withInputData(new QFunctionInputMetaData().withFields(List.of(new QFieldMetaData("requestId", QFieldType.STRING), + new QFieldMetaData("printerRowId", QFieldType.STRING).withPossibleValueSourceName(PrintingTables.PRINTER), + new QFieldMetaData("title", QFieldType.STRING), new QFieldMetaData("format", QFieldType.STRING).withPossibleValueSourceName("printing_format"), + new QFieldMetaData("contentBase64", QFieldType.STRING), new QFieldMetaData("options", QFieldType.STRING)))))); + } + + + /*************************************************************************** + ** Direct step invocation receives the same current-actor checks as native HTTP. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + try + { + JSONObject request = new JSONObject(); + for(String field : List.of("requestId", "printerRowId", "title", "format", "contentBase64")) + { + request.put(field, input.getValueString(field)); + } + String options = input.getValueString("options"); + if(options != null) + { + request.put("options", new JSONObject(options)); + } + output.addValue("job", PrintingOperatorAuthorization.submit(request).toString()); + } + catch(QException exception) + { + throw exception; + } + catch(Exception exception) + { + throw new QException("Print submission failed", exception); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java index a0b0efa..458b7fe 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java @@ -36,6 +36,22 @@ public class PrintingQBitConfig implements QBitConfig private String storageTableName; private String schedulerName; private String facilityPossibleValueSourceName; + private transient RetainedPrintSourceAuthority retainedSourceAuthority; + + + /** Explicit trusted host integration; absent means retained submissions are disabled. */ + public PrintingQBitConfig withRetainedSourceAuthority(RetainedPrintSourceAuthority value) + { + retainedSourceAuthority = java.util.Objects.requireNonNull(value); + return (this); + } + + + /** Never serialized into client metadata or supplied by a request. */ + public RetainedPrintSourceAuthority getRetainedSourceAuthority() + { + return (retainedSourceAuthority); + } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index af3f693..4a9a661 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -111,6 +111,7 @@ public class PrintingQBitProducer implements QBitProducer QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at")); printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved")); printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available")); + printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media")); QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id")); jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)")); QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code")); @@ -173,7 +174,9 @@ 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); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java index 04ac760..ffed478 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java @@ -43,7 +43,15 @@ public class PrintingReadPermission extends PrintingAdminPermission @Override public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException { - super.checkPermissionsThrowing(input, metadata); + if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table + && (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName()))) + { + new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata); + } + else + { + super.checkPermissionsThrowing(input, metadata); + } if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput) && !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput)) { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java index 44834b5..0e9a793 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -252,7 +252,31 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf } finally { - workers.shutdown(); + 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(); } } @@ -437,8 +461,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf { if(hello != null || !payload.keySet().equals(Set.of("agentVersion", "capabilities", "lastAppliedRevision", "lastSettingsRevision", "limits")) || !payload.getJSONArray("capabilities").toList().contains("realtime-v1") - || payload.getJSONArray("capabilities").length() > 3 - || !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL").containsAll(payload.getJSONArray("capabilities").toList())) + || payload.getJSONArray("capabilities").length() > 4 + || !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList())) { throw new PrintingException(409, "Realtime capability is required"); } @@ -545,7 +569,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf JSONArray capabilities = new JSONArray(); for(Object value : hello.getJSONArray("capabilities")) { - if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL").contains(value)) + if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value)) { capabilities.put(value); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java index 29b7fc6..7546b7b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java @@ -86,7 +86,17 @@ public final class PrintingRecordDocuments ***************************************************************************/ public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException { - try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle)) + return (pdf(title, subtitle, sections, children, null)); + } + + + + /*************************************************************************** + ** Render sections and child list summaries under the app's header/footer. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, PrintingChrome chrome) throws QException + { + try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome)) { int columns = layout.columns(BODY_SIZE); int labelWidth = Math.min(28, Math.max(8, sections.stream().flatMap(s -> s.fields().keySet().stream()).mapToInt(String::length).max().orElse(8) + 1)); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java index 4ed48dd..dda9ce6 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java @@ -216,9 +216,20 @@ public final class PrintingRecordProcesses /*************************************************************************** ** Render one record (display values, visible non-binary fields) and queue it. ***************************************************************************/ - static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request) throws QException + static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, PrintingChrome chrome) throws QException { Serializable primaryKey = selected.getValue(table.getPrimaryKeyField()); + PrintingListProcesses.Rendered rendered = render(table, primaryKey, chrome); + return (queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))); + } + + + + /*************************************************************************** + ** The record sheet of one record (read with the caller's permissions). + ***************************************************************************/ + static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, PrintingChrome chrome) throws QException + { GetInput get = new GetInput(); get.setPermissionEnforcement(PermissionEnforcement.ENFORCE); get.setTableName(table.getName()); @@ -232,9 +243,10 @@ public final class PrintingRecordProcesses } String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey); String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel(); - String subtitle = tableLabel + " " + primaryKey + " - " + printedBy(); - byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record)); - return (queue(printer, tableLabel + ": " + label, bytes, copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))); + String subtitle = tableLabel + " " + primaryKey; + PrintingChrome pageChrome = chrome == null ? PrintingChrome.defaults(tableLabel + " › " + label) : chrome.withTitleIfBlank(tableLabel + " › " + label); + byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record), pageChrome); + return (new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null)); } @@ -489,7 +501,7 @@ public final class PrintingRecordProcesses QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE)); for(QRecord record : input.getRecords()) { - jobIds.add(submit(table, record, printer, copies, request)); + jobIds.add(submit(table, record, printer, copies, request, PrintingChrome.from(input, null))); done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField())); output.getRecords().add(record); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java new file mode 100644 index 0000000..40d9a8d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java @@ -0,0 +1,150 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Locale; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** printingRenderToPdf: render the same document a print job would carry - a + ** record sheet, a table list or a text page - and hand it to the caller as a + ** download instead of queueing it on a printer ("Save as PDF"). + ** + ** Values: kind RECORD (tableName, recordId), LIST (the printingPrintTableList + ** values: tableName, columns, columnWidths, filterJSON, scope, maxRows, + ** pageSize, orientation, title, filterDescription) or TEXT (title, subtitle, + ** text, orientation, pageSize). The result carries downloadFileName and + ** serverFilePath for GET /download/{file} (the standard process download), + ** and printingMessage when a list left rows out. No printer is needed; reads + ** enforce the caller's table and record permissions. + *******************************************************************************/ +public final class PrintingRenderProcesses +{ + public static final String RENDER = "printingRenderToPdf"; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingRenderProcesses() + { + } + + + + /*************************************************************************** + ** Register the process. + ***************************************************************************/ + public static void register(QInstance instance) + { + instance.addProcess(new QProcessMetaData().withName(RENDER).withLabel("Save as PDF").withPermissionRules(PrintingProcesses.permissionRules()) + .withStep(new QBackendStepMetaData().withName("render").withCode(new QCodeReference(Render.class))) + .withStep(new QFrontendStepMetaData().withName("download").withLabel("Download") + .withViewFields(List.of(new QFieldMetaData("downloadFileName", QFieldType.STRING).withLabel("File"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.DOWNLOAD_FORM)))); + } + + + + /*************************************************************************** + ** The download name: the document title made file-safe, with .pdf. + ***************************************************************************/ + static String fileName(String title) + { + String stem = (title == null ? "" : title).replaceAll("[^A-Za-z0-9._-]+", "-").replaceAll("^[-.]+|-+$", ""); + return ((stem.isEmpty() ? "document" : PrintingRecordDocuments.truncate(stem, 80).replace("~", "")) + ".pdf"); + } + + + + /*************************************************************************** + ** Render and register the file for download. + ***************************************************************************/ + public static class Render implements BackendStep + { + /*************************************************************************** + ** One PDF, delivered through the standard process download. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + PrintingProcesses.currentService(); + String kind = input.getValueString("kind") == null ? "" : input.getValueString("kind").toUpperCase(Locale.ROOT); + PrintingListProcesses.Rendered rendered = switch(kind) + { + case "RECORD" -> + { + QTableMetaData table = PrintingListProcesses.table(input); + String id = input.getValueString("recordId"); + if(id == null || id.isBlank()) + { + throw new QException("Give the record to save (recordId)"); + } + yield PrintingRecordProcesses.render(table, com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), id), PrintingChrome.from(input, null)); + } + case "LIST" -> PrintingListProcesses.render(input, true); + case "TEXT" -> PrintingListProcesses.render(input, false); + default -> throw new QException("kind must be RECORD, LIST or TEXT"); + }; + try + { + File file = Files.createTempFile("qqq-print-", ".pdf").toFile(); + file.deleteOnExit(); + Files.write(file.toPath(), rendered.bytes()); + output.addValue("downloadFileName", fileName(rendered.title())); + output.addValue("serverFilePath", ProcessFileDownload.register(file)); + output.addValue("printingTitle", rendered.title()); + output.addValue("printingBytes", rendered.bytes().length); + if(rendered.message() != null) + { + output.addValue("printingTruncated", true); + output.addValue("printingMessage", rendered.message().replace("Printed the first", "Saved the first").replace("the print limit", "the limit").replace("to print the rest", "to save the rest")); + } + } + catch(IOException exception) + { + throw new QException("Could not write the PDF for download", exception); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 9543efd..956a671 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -277,9 +277,14 @@ public class PrintingService "CREATE TABLE IF NOT EXISTS printing_device (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, facility_id VARCHAR(200) NOT NULL, name VARCHAR(200) NOT NULL, agent_version VARCHAR(100) NOT NULL, token_hash VARCHAR(64) UNIQUE NOT NULL, last_seen_at VARCHAR(40) NOT NULL, enabled INTEGER NOT NULL)", "CREATE TABLE IF NOT EXISTS printing_printer (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, name VARCHAR(300) NOT NULL, driver_name VARCHAR(300), port_name VARCHAR(300), status VARCHAR(40) NOT NULL, formats VARCHAR(100) NOT NULL, enabled INTEGER NOT NULL, last_seen_at VARCHAR(40) NOT NULL, UNIQUE(device_id, printer_id))", "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS available INTEGER DEFAULT 0 NOT NULL", + "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS media_profiles_json TEXT DEFAULT '[]' NOT NULL", "CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))", "CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS retained_actor_id VARCHAR(200)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS retained_binding_json VARCHAR(16000)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS accepted_event_id VARCHAR(128)", + "ALTER TABLE printing_event ADD COLUMN IF NOT EXISTS event_origin VARCHAR(40)", "ALTER TABLE printing_job ALTER COLUMN content_base64 DROP NOT NULL", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_available INTEGER DEFAULT 1 NOT NULL", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS completed_at VARCHAR(40)", @@ -780,6 +785,7 @@ public class PrintingService update(connection, "INSERT INTO printing_printer (id, tenant_id, device_id, printer_id, name, driver_name, port_name, status, formats, enabled, last_seen_at, available, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?)", UUID.randomUUID().toString(), tenantId, deviceId, printerId, name, driver, port, status, formats, now, name); } String rowId = query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).getFirst().getString("id"); + update(connection, "UPDATE printing_printer SET media_profiles_json=? WHERE id=?", PrintingMediaProfile.inventory(printer).toString(), rowId); updateProfile(connection, "printing_printer", rowId, printer, "displayName", "queue_name"); } update(connection, "UPDATE printing_device SET last_seen_at=? WHERE id=?", Instant.now().toString(), deviceId); @@ -818,6 +824,15 @@ public class PrintingService ***************************************************************************/ public JSONObject submit(JSONObject request) throws SQLException { + return (submitAuthorized(request, null, null)); + } + + + /** Native operators retain current authority throughout shared queue admission. */ + JSONObject submitAuthorized(JSONObject request, String printerRowId, SubmissionAuthorization authorize) throws SQLException + { + SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(printerRowId, authorize); + checkSubmission(guard); String format = required(request.optString("format"), "format"); long maximum = realtimePolicy().documentLimit(format); if(request.getString("contentBase64").length() > ((maximum + 2) / 3) * 4) @@ -845,7 +860,47 @@ public class PrintingService { throw new PrintingException(400, "PDF header is missing"); } - return (submitPrepared(request, hash(content), content.length, connection -> content)); + return (submitPrepared(request, hash(content), content.length, connection -> content, null, guard)); + } + + + /** A caller-owned current authorization check, never externally asserted proof. */ + @FunctionalInterface + interface SubmissionAuthorization + { + /** Recheck caller identity, current ceilings and exact destination. */ + void check() throws com.kingsrook.qqq.backend.core.exceptions.QException; + } + + + /** Native row identity and live authority are independent of retained-source proof. */ + private record SubmissionGuard(String printerRowId, SubmissionAuthorization authorize) + { + /** Host callbacks cannot retarget an authorized native row to another queue. */ + void requirePrinter(JSONObject row, String field) + { + if(!printerRowId.equals(row.optString(field))) + { + throw new PrintingException(409, "The authorized print destination changed"); + } + } + } + + + /** Preserve checked native authorization through the SQL transaction boundary. */ + private static void checkSubmission(SubmissionGuard guard) throws SQLException + { + if(guard != null) + { + try + { + guard.authorize().check(); + } + catch(com.kingsrook.qqq.backend.core.exceptions.QException exception) + { + throw new SQLException("Print submission authorization failed", exception); + } + } } @@ -869,6 +924,58 @@ public class PrintingService ***************************************************************************/ private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException { + return (submitPrepared(request, digest, contentLength, loader, null, null)); + } + + + /** Only the native facade supplies retained bindings and current-authority checks. */ + JSONObject submitRetained(JSONObject request, JSONObject binding, String actor, RetainedPrintFacade.Checked authorize, RetainedContent loader) throws SQLException + { + binding.put("options", printOptions(request)); + if(binding.toString().length() > 16000) + { + throw new PrintingException(400, "Retained print binding is too large"); + } + JSONObject source = binding.getJSONObject("source"); + BoundSubmission bound = new BoundSubmission(binding.toString(), actor); + return (submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection -> + { + try + { + byte[] bytes = loader.read(); + if("PDF".equals(request.getString("format")) && !new String(bytes, 0, Math.min(5, bytes.length), StandardCharsets.US_ASCII).equals("%PDF-")) + { + throw new PrintingException(400, "PDF header is missing"); + } + return bytes; + } + catch(com.kingsrook.qqq.backend.core.exceptions.QException exception) + { + throw new SQLException("Retained content authorization failed", exception); + } + }, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run))); + } + + + /** Deferred current-owner content read after shared capacity admission. */ + @FunctionalInterface + interface RetainedContent + { + /** Return only authorized exact bytes. */ + byte[] read() throws com.kingsrook.qqq.backend.core.exceptions.QException; + } + + + /** Request-local immutable binding; never an externally accepted JSON proof. */ + private record BoundSubmission(String json, String actor) + { + } + + + /** Reuse the existing storage reservation, queue admission, and commit path. */ + private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, BoundSubmission bound, SubmissionGuard guard) throws SQLException + { + checkSubmission(guard); String deviceId = required(request.optString("deviceId"), "deviceId"); String printerId = required(request.optString("printerId"), "printerId"); String key = required(request.optString("idempotencyKey"), "idempotencyKey"); @@ -898,16 +1005,32 @@ public class PrintingService requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); requireDevice(connection, deviceId); - List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); + 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); if(!previous.isEmpty()) { JSONObject old = previous.getFirst(); + if(guard != null) + { + guard.requirePrinter(old, "printer_row_id"); + } + if(key.startsWith("operator:") && !old.getString("title").equals(title)) + { + throw new PrintingException(409, "Idempotency key was already used for a different request"); + } + if((bound == null) != old.isNull("retained_binding_json") + || (bound != null && (!new JSONObject(bound.json()).similar(new JSONObject(old.getString("retained_binding_json"))) || !bound.actor().equals(old.getString("retained_actor_id"))))) + { + throw new PrintingException(409, "Idempotency key belongs to a different accepted source"); + } if(!old.getString("device_id").equals(deviceId) || !old.getString("printer_id").equals(printerId) || !old.getString("content_hash").equals(digest) || !old.getString("format").equals(format) || !new JSONObject(old.getString("options_json")).similar(options)) { throw new PrintingException(409, "Idempotency key was already used for a different request"); } + JSONObject replay = bound == null ? job(old) : accepted(connection, old); + checkSubmission(guard); connection.commit(); - return (job(old)); + return (replay); } if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty()) { @@ -933,10 +1056,16 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } + if(guard != null) + { + guard.requirePrinter(printers.getFirst(), "id"); + } if(contentLength > contentLimit) { throw new PrintingException(413, "Document exceeds the current configured format limit"); } + PrintingMediaProfile.requireCurrent(printers.getFirst(), options); + checkSubmission(guard); PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).effective(connection)); if(storageTableName != null) { @@ -955,26 +1084,44 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } + if(guard != null) + { + guard.requirePrinter(printers.getFirst(), "id"); + } } + PrintingMediaProfile.requireCurrent(printers.getFirst(), options); + checkSubmission(guard); byte[] content = loader.load(connection); if(content.length != contentLength || !hash(content).equals(digest)) { throw new PrintingException(409, "Stored print document integrity validation failed"); } + checkSubmission(guard); if(storageTableName != null) { documentStorage.put(storageTableName, reference, format, content); } + checkSubmission(guard); update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, reference, binding); + if(bound != null) + { + String eventId = UUID.randomUUID().toString(); + String acceptedAt = Instant.now().toString(); + update(connection, "UPDATE printing_job SET retained_actor_id=?, retained_binding_json=?, accepted_event_id=? WHERE id=?", bound.actor(), bound.json(), eventId, id); + JSONObject event = new JSONObject().put("type", "ACCEPTED").put("origin", "SERVER_ACCEPTANCE").put("status", "QUEUED"); + update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, 'QUEUED', ?, ?, 'SERVER_ACCEPTANCE')", eventId, id, event.toString(), acceptedAt); + } update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); - JSONObject result = job(query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=?", id).getFirst()); + JSONObject stored = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE id=?", id).getFirst(); + JSONObject result = bound == null ? job(stored) : accepted(connection, stored); + checkSubmission(guard); connection.commit(); realtimeBus.publish(tenantId, deviceId); return (result); } catch(Exception exception) { - connection.rollback(); + rollbackAfterFailure(connection, exception); if(uploadedId != null) { try @@ -983,7 +1130,10 @@ public class PrintingService } catch(Exception cleanupFailure) { - exception.addSuppressed(cleanupFailure); + if(cleanupFailure != exception) + { + exception.addSuppressed(cleanupFailure); + } } } throw exception; @@ -997,6 +1147,13 @@ public class PrintingService ** Preserve request intent: reject malformed types instead of coercing them. ***************************************************************************/ private JSONObject printOptions(JSONObject request) throws SQLException + { + return (printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies")))); + } + + + /** Normalize an immutable intent without reading or creating tenant settings during reconciliation. */ + private static JSONObject printOptions(JSONObject request, int maximumCopies) { if(request.has("options") && !(request.opt("options") instanceof JSONObject)) { @@ -1021,7 +1178,7 @@ public class PrintingService } } Object scale = options.has("scale") ? options.opt("scale") : "ACTUAL_SIZE"; - if(count < 1 || count > realtimePolicy().get("maxCopies") || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) + if(count < 1 || count > maximumCopies || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) { throw new PrintingException(400, "Invalid print options"); } @@ -1029,6 +1186,7 @@ public class PrintingService { throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue"); } + PrintingMediaProfile.selection(options); options.put("copies", count).put("scale", scale); if(options.toString().length() > 2000) { @@ -1179,7 +1337,7 @@ public class PrintingService requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); requireDevice(connection, deviceId); - JSONObject control = query(connection, "SELECT dispatch_paused,connection_state FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst(); + JSONObject control = query(connection, "SELECT dispatch_paused,connection_state,control_capabilities FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst(); if(control.getInt("dispatch_paused") == 1 || !"CONNECTED".equals(control.getString("connection_state"))) { connection.commit(); @@ -1192,6 +1350,11 @@ public class PrintingService return (new JSONObject().put("job", JSONObject.NULL)); } JSONObject selected = candidates.getFirst(); + if(new JSONObject(selected.getString("options_json")).has("mediaProfile") + && !new JSONArray(control.optString("control_capabilities", "[]")).toList().contains("printer-media-v1")) + { + throw new PrintingException(409, "This job requires an agent that validates printer media profiles"); + } if("QUEUED".equals(selected.getString("status"))) { update(connection, "UPDATE printing_job SET status='CLAIMED' WHERE id=?", selected.getString("id")); @@ -1313,7 +1476,7 @@ public class PrintingService throw new PrintingException(409, "Invalid state transition"); } String eventTime = Instant.now().toString(); - update(connection, "INSERT INTO printing_event VALUES (?, ?, ?, ?, ?)", eventId, jobId, status, event.toString(), eventTime); + update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, ?, ?, ?, 'DEVICE_OBSERVATION')", eventId, jobId, status, event.toString(), eventTime); if(Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(status)) { update(connection, "UPDATE printing_job SET completed_at=? WHERE id=?", eventTime, jobId); @@ -1390,7 +1553,7 @@ public class PrintingService for(JSONObject row : rows.subList(0, Math.min(limit, rows.size()))) { result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("name", row.getString("queue_name")).put("displayName", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")) - .put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at"))); + .put("mediaProfiles", new JSONArray(row.getString("media_profiles_json"))).put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at"))); } JSONObject response = new JSONObject().put("printers", result).put("nextCursor", JSONObject.NULL); if(rows.size() > limit) @@ -1496,6 +1659,126 @@ public class PrintingService + /** Cleanup never replaces the acceptance/authorization failure that caused rollback. */ + static void rollbackAfterFailure(Connection connection, Exception original) + { + try + { + connection.rollback(); + } + catch(Exception cleanupFailure) + { + if(cleanupFailure != original) + { + original.addSuppressed(cleanupFailure); + } + } + } + + + /** Exact accepted-source lookup; unresolved object reservations never mean absence. */ + JSONObject findRetained(String key, JSONObject expected) throws SQLException + { + if(expected != null) + { + expected.put("options", printOptions(new JSONObject().put("options", expected.getJSONObject("options")), 32767)); + } + try(Connection connection = dataSource.getConnection()) + { + connection.setAutoCommit(false); + try + { + PrintingAdmission.lock(connection, tenantId); + JSONObject result = findRetained(connection, key, expected); + connection.commit(); + return (result); + } + catch(Exception exception) + { + rollbackAfterFailure(connection, exception); + throw exception; + } + } + } + + + /** The existing admission lock makes absence consistent with in-flight submissions. */ + private JSONObject findRetained(Connection connection, String key, JSONObject expected) throws SQLException + { + List<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. ***************************************************************************/ @@ -1509,6 +1792,32 @@ public class PrintingService } + /** The native row accepted with an operator request is private authorization data. */ + record OperatorAcceptance(JSONObject job, String printerRowId) + { + } + + + /** Read the exact committed row binding; a replacement queue cannot inherit history. */ + OperatorAcceptance findOperatorAcceptance(String key) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + List<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 diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java index 2a45b56..a276182 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java @@ -51,7 +51,7 @@ public final class PrintingTableDocuments ***************************************************************************/ public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException { - return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT")); + return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null)); } @@ -63,7 +63,7 @@ public final class PrintingTableDocuments ** the columns do not fit portrait. The header repeats on every page; the ** footer text goes at the bottom left of every page. ***************************************************************************/ - public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation) throws QException + public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation, PrintingChrome chrome) throws QException { if(columns.isEmpty()) { @@ -78,7 +78,7 @@ public final class PrintingTableDocuments } } boolean landscape = isLandscape(orientation, natural, pageSize); - try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) { layout.footer(footer); int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE)); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java index 5e2f83a..fdf6f58 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java @@ -50,6 +50,16 @@ public final class PrintingTextDocuments ** Render text on LETTER or A4, portrait or landscape. ***************************************************************************/ public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape) throws QException + { + return (pdf(title, subtitle, text, pageSize, landscape, null)); + } + + + + /*************************************************************************** + ** Render text under the app's header/footer. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape, PrintingChrome chrome) throws QException { if(text == null || text.isEmpty()) { @@ -59,7 +69,7 @@ public final class PrintingTextDocuments { throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)"); } - try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) { String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1); float size = fontSize(lines, layout.columns(1) * 1f); @@ -99,4 +109,62 @@ public final class PrintingTextDocuments float fits = charactersAtOnePoint / widest; return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2))); } + + + + /*************************************************************************** + ** One section of a content document: a title (blank for none) and its + ** lines, as the client read them from a screen panel. + ***************************************************************************/ + public record Section(String title, java.util.List<String> lines) + { + } + + + + /*************************************************************************** + ** A screen's content as a document: each section's title as a heading, + ** its lines in the monospace face, long lines wrapped at spaces to the page + ** width (no borders, no navigation). + ***************************************************************************/ + public static byte[] sectionsPdf(String title, String subtitle, java.util.List<Section> sections, String pageSize, boolean landscape, PrintingChrome chrome) throws QException + { + if(sections.isEmpty() || sections.stream().allMatch(section -> section.lines().stream().allMatch(String::isBlank) && (section.title() == null || section.title().isBlank()))) + { + throw new QException("There is no content to print"); + } + int total = sections.stream().mapToInt(section -> section.lines().stream().mapToInt(String::length).sum()).sum(); + if(total > MAX_CHARACTERS) + { + throw new QException("The content is too long to print (at most " + MAX_CHARACTERS + " characters)"); + } + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) + { + int width = layout.columns(FONT_SIZE); + boolean first = true; + for(Section section : sections) + { + if(section.title() != null && !section.title().isBlank()) + { + layout.gap(first ? 0 : 8); + layout.keep(3, FONT_SIZE); + layout.line(section.title(), 11); + layout.gap(2); + } + first = false; + for(String line : section.lines()) + { + for(String wrapped : PrintingRecordDocuments.wrap(line.stripTrailing(), width)) + { + layout.line(wrapped, FONT_SIZE); + } + } + } + return (layout.finish()); + } + catch(IOException exception) + { + throw new QException("Could not render the content: " + exception.getMessage(), exception); + } + } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java index a5ed708..9b88e2c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java @@ -49,11 +49,11 @@ public final class PrintingUiApi MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension()); } - /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes. */ + /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade. */ public static MiddlewareSpecExtension extension() { - return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.1.0") - .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes.") + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0") + .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1.") .withSpec(Session.class) .withSpec(Bind.class) .withSpec(Enroll.class) @@ -70,7 +70,43 @@ public final class PrintingUiApi .withSpec(Receipt.class) .withSpec(Action.class) .withSpec(Commands.class) - .withSpec(Command.class)); + .withSpec(Command.class) + .withSpec(OperatorCatalog.class) + .withSpec(OperatorSubmit.class) + .withSpec(OperatorRequest.class)); + } + + + /** Current-actor, approved/exposed printer catalog; no administrative permission. */ + public static class OperatorCatalog extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorCatalog() + { + super("OperatorCatalog", HttpMethod.GET, "/operator/printers", null, "OperatorCatalog"); + } + } + + + /** Operator document submission uses the existing durable queue. */ + public static class OperatorSubmit extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorSubmit() + { + super("OperatorSubmit", HttpMethod.POST, "/operator/jobs", "OperatorSubmit", "Job"); + } + } + + + /** Exact actor/request lookup replaces general job history access. */ + public static class OperatorRequest extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorRequest() + { + super("OperatorRequest", HttpMethod.GET, "/operator/requests/{requestId}", null, "Job"); + } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java index 1e4a7ce..d0f557b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java @@ -72,7 +72,8 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec { return (new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path) .withOperationId("printing" + operation).withShortSummary("Printing " + operation) - .withLongDescription("Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.")); + .withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority." + : "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.")); } @@ -111,7 +112,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec parameters.add(new Parameter().withName(PrintingAgentAuthorization.DEVICE_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(36))); parameters.add(new Parameter().withName(PrintingAgentAuthorization.TOKEN_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(128)).withDescription("Private secondary device proof; never log.")); } - for(String name : List.of("deviceId", "jobId", "commandId")) + for(String name : List.of("deviceId", "jobId", "commandId", "requestId")) { if(path.contains("{" + name + "}")) { @@ -123,6 +124,11 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50.")); parameters.add(new Parameter().withName("before").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(128))); } + if("OperatorCatalog".equals(operation)) + { + parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50.")); + parameters.add(new Parameter().withName("after").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(200))); + } return (parameters); } @@ -132,8 +138,22 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec public JsonInput buildInput(Context context) throws Exception { context.header("Cache-Control", "no-store"); - PrintingControlAuthorization.principal(); - if(context.bodyAsBytes().length > ("Inventory".equals(operation) ? 2097152 : 32768)) + if(operation.startsWith("Operator")) + { + PrintingOperatorAuthorization.principal(); + } + else + { + PrintingControlAuthorization.principal(); + } + long maximum = "Inventory".equals(operation) ? 2097152 : 32768; + if("OperatorSubmit".equals(operation)) + { + PrintingOperatorAuthorization.requireSubmission(); + PrintingRealtimePolicy policy = PrintingProcesses.currentService().realtimePolicy(); + maximum = ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536; + } + if(context.bodyAsBytes().length > maximum) { throw new PrintingException(413, "Printing request exceeds its size limit"); } @@ -157,6 +177,17 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec protected Object handle(JsonInput input) throws Exception { PrintingService service = PrintingProcesses.currentService(); + if(operation.startsWith("Operator")) + { + JSONObject result = switch(operation) + { + case "OperatorCatalog" -> PrintingOperatorAuthorization.catalog(input.getQueryParamInteger("limit") == null ? 50 : input.getQueryParamInteger("limit"), input.getQueryParam("after")); + case "OperatorSubmit" -> PrintingOperatorAuthorization.submit(new JSONObject(input.getBody())); + case "OperatorRequest" -> PrintingOperatorAuthorization.observe(input.getPathParam("requestId")); + default -> throw new PrintingException(400, "Unknown operator operation"); + }; + return result.toMap(); + } String principal = PrintingControlAuthorization.principal(); String device = input.getPathParam("_verifiedDevice"); JSONObject body = new JSONObject(input.getBody()); diff --git a/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java new file mode 100644 index 0000000..e9a64ee --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java @@ -0,0 +1,358 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.sql.SQLException; +import java.time.Instant; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; + + +/******************************************************************************* + ** Native authorized acceptance of exact retained originals. No device wire DTO + ** changes, owner-specific dependencies, alternate queue, or impersonation. + *******************************************************************************/ +public final class RetainedPrintFacade +{ + /** Immutable owner metadata; the canonical reference binds source/version/generation/actor/tenant, never credentials. */ + public record SourceReference(String owner, String opaqueReference, String sha256, long sizeBytes, String mediaType) + { + /** Validate before allocation, persistence or native dispatch. */ + public SourceReference + { + text(owner, 100); + text(opaqueReference, 4000); + if(sizeBytes < 1 || sizeBytes > Integer.MAX_VALUE || sha256 == null || !sha256.matches("[a-f0-9]{64}") + || !("application/pdf".equals(mediaType) || "application/vnd.zebra-zpl".equals(mediaType))) + { + throw new PrintingException(400, "Invalid retained source metadata"); + } + } + + /** Fixed fields, not an arbitrary caller JSON document. */ + JSONObject json() + { + return (new JSONObject().put("owner", owner).put("opaqueReference", opaqueReference).put("sha256", sha256) + .put("sizeBytes", sizeBytes).put("mediaType", mediaType)); + } + } + + /** Caller chooses only an intent, owner locator and native destination, not an actor. */ + public record Request(String requestId, String sourceLocator, String printerRecordId, String title, String optionsJson) + { + /** Freeze and bound the complete input before invoking trusted adapters. */ + public Request + { + requestIdentity(requestId); + text(sourceLocator, 200); + text(printerRecordId, 36); + text(title, 1024); + if(optionsJson == null || optionsJson.length() > 2000) + { + throw new PrintingException(400, "Invalid print options"); + } + new JSONObject(optionsJson); + } + } + + /** Server acceptance is queue durability, not device or physical-output evidence. */ + public record Acceptance(String requestId, String jobId, String acceptedEventId, Instant acceptedAt, String tenantId, + String actorId, SourceReference source, String deviceId, String printerId, String printerRecordId, String title, String optionsJson) + { + } + + /** Exact immutable device observation, separate from the server acceptance event. */ + public record Observation(Acceptance acceptance, String eventId, Instant recordedAt, String state, String spoolerJobId) + { + } + + /** Resolve current source and destination authority, then use the existing queue transaction. */ + public Acceptance submit(Request request) throws QException + { + Prepared prepared = prepare(request, true); + try + { + JSONObject result = prepared.service().submitRetained(prepared.wire(), prepared.binding(), prepared.actor(), + () -> prepared.check(), () -> + { + prepared.check(); + byte[] bytes = prepared.authority().read(prepared.source(), request.requestId()); + prepared.check(); + if(bytes == null || bytes.length != prepared.source().sizeBytes()) + { + throw new QException("Retained source changed its content length"); + } + return bytes; + }); + Acceptance accepted = acceptance(result); + checkDestination(accepted); + prepared.check(); + return (accepted); + } + catch(SQLException exception) + { + throw new QException("Retained print acceptance unavailable; reconcile the same request identity", exception); + } + } + + /** Absence is returned only when neither an accepted job nor an unresolved upload exists. */ + public Acceptance find(Request request) throws QException + { + Prepared prepared = prepare(request, false); + try + { + JSONObject row = prepared.service().findRetained(key(request.requestId()), prepared.binding()); + prepared.check(); + if(row == null) + { + return null; + } + Acceptance accepted = acceptance(row); + checkDestination(accepted); + prepared.check(); + return (accepted); + } + catch(SQLException exception) + { + throw new QException("Retained print lookup unavailable", exception); + } + } + + /** Current operator needs owner observation authority and the exact destination, never generic job access. */ + public Observation observation(String requestId, String eventId) throws QException + { + requestIdentity(requestId); + text(eventId, 128); + String reporter = PrintingOperatorAuthorization.principal(); + requireStorage(false); + PrintingService service = PrintingProcesses.currentService(); + RetainedPrintSourceAuthority authority = authority(service); + var instance = QContext.getQInstance(); + var session = QContext.getQSession(); + try + { + JSONObject stored = service.findRetained(key(requestId), null); + if(stored == null) + { + throw new PrintingException(404, "Accepted print intent not found"); + } + Acceptance accepted = acceptance(stored); + checkDestination(accepted); + JSONObject proof = service.retainedObservation(key(requestId), eventId); + if(!accepted.equals(acceptance(proof.getJSONObject("acceptance")))) + { + throw new PrintingException(409, "Accepted print binding changed"); + } + authority.authorizeObservation(accepted.source(), requestId); + if(instance != QContext.getQInstance() || session != QContext.getQSession() + || !reporter.equals(PrintingOperatorAuthorization.principal()) || service != PrintingProcesses.currentService() + || authority != authority(service)) + { + throw new QException("Printing reporter authority changed"); + } + requireStorage(false); + checkDestination(accepted); + return (new Observation(accepted, eventId, Instant.parse(proof.getString("recordedAt")), proof.getString("state"), + proof.isNull("spoolerJobId") ? null : proof.getString("spoolerJobId"))); + } + catch(SQLException exception) + { + throw new QException("Committed print observation unavailable", exception); + } + } + + /** Capture one native request context and trusted configuration; callbacks cannot retarget it. */ + private Prepared prepare(Request request, boolean mutation) throws QException + { + Objects.requireNonNull(request); + if(QContext.getQBackendTransaction() != null) + { + throw new QException("Retained print acceptance requires its own committed transaction"); + } + String actor = PrintingOperatorAuthorization.principal(); + text(actor, 200); + requireStorage(mutation); + if(mutation) + { + PrintingOperatorAuthorization.requireSubmission(); + } + PrintingService service = PrintingProcesses.currentService(); + var instance = QContext.getQInstance(); + var session = QContext.getQSession(); + RetainedPrintSourceAuthority authority = authority(service); + QRecord printer = mutation ? PrintingOperatorAuthorization.destination(request.printerRecordId()) + : PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId()); + String device = printer.getValueString("device_id"); + String printerId = printer.getValueString("printer_id"); + SourceReference source = Objects.requireNonNull(authority.resolve(request.sourceLocator(), request.requestId())); + JSONObject wire = new JSONObject().put("deviceId", device).put("printerId", printerId).put("idempotencyKey", key(request.requestId())) + .put("title", request.title()).put("format", "application/pdf".equals(source.mediaType()) ? "PDF" : "ZPL") + .put("options", new JSONObject(request.optionsJson())); + JSONObject binding = new JSONObject().put("requestId", request.requestId()).put("actorId", actor).put("source", source.json()) + .put("printerRecordId", request.printerRecordId()).put("deviceId", device).put("printerId", printerId).put("title", request.title()) + .put("options", new JSONObject(request.optionsJson())).put("tenantId", service.getTenantId()); + Checked check = () -> + { + requireStorage(mutation); + if(mutation) + { + PrintingOperatorAuthorization.requireSubmission(); + } + if(instance != QContext.getQInstance() || session != QContext.getQSession() || QContext.getQBackendTransaction() != null + || service != PrintingProcesses.currentService() || authority != authority(service) || !actor.equals(PrintingOperatorAuthorization.principal())) + { + throw new QException("Retained print authority changed"); + } + QRecord current = mutation ? PrintingOperatorAuthorization.printer(request.printerRecordId(), wire.getString("format")) + : PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId()); + if(!device.equals(current.getValueString("device_id")) || !printerId.equals(current.getValueString("printer_id"))) + { + throw new QException("Retained print destination changed"); + } + }; + check.run(); + Prepared prepared = new Prepared(service, authority, source, actor, wire, binding, () -> + { + check.run(); + SourceReference current = authority.resolve(request.sourceLocator(), request.requestId()); + check.run(); + if(!source.equals(current)) + { + throw new QException("Retained print source changed"); + } + }); + prepared.check(); + return (prepared); + } + + + /** Historical evidence requires current record authority without granting generic queue reads. */ + private static void checkDestination(Acceptance accepted) throws QException + { + QRecord printer = PrintingOperatorAuthorization.authorizeDestinationHistory(accepted.printerRecordId()); + if(!accepted.deviceId().equals(printer.getValueString("device_id")) || !accepted.printerId().equals(printer.getValueString("printer_id"))) + { + throw new QException("Accepted print destination changed"); + } + } + + /** Keep owner service writes within native credential/channel ceilings, without generic edit roles. */ + private static void requireStorage(boolean mutation) throws QException + { + var instance = QContext.getQInstance(); + var table = instance == null ? null : instance.getTable(PrintingTables.JOB); + if(table == null) + { + throw new QException("Printing job metadata is unavailable"); + } + var rules = PermissionsHelper.getEffectivePermissionRules(table, instance); + if(PermissionsHelper.isDeniedForClientChannel(table, rules) + || (mutation && PermissionsHelper.isWriteDeniedForClientChannel(table, rules)) + || PermissionsHelper.isOutsideCredentialScope(table, mutation ? TablePermissionSubType.INSERT : TablePermissionSubType.READ)) + { + throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Current credential or client channel cannot access print acceptance"); + } + } + + /** Only the host's matching instance configuration can supply owner authority. */ + private static RetainedPrintSourceAuthority authority(PrintingService service) throws QException + { + for(var metadata : QContext.getQInstance().getQBits().values()) + { + if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() == service && config.getRetainedSourceAuthority() != null) + { + return (config.getRetainedSourceAuthority()); + } + } + throw new QException("Retained printing is not configured"); + } + + /** Package-private checked transaction callbacks never originate in request JSON. */ + @FunctionalInterface + interface Checked + { + /** Reauthorize without committing any printing state. */ + void run() throws QException; + } + + /** One request-local immutable capture; JSON never escapes to callers. */ + private record Prepared(PrintingService service, RetainedPrintSourceAuthority authority, SourceReference source, + String actor, JSONObject wire, JSONObject binding, Checked verifier) + { + /** Run the exact capture's current-authority check. */ + void check() throws QException + { + verifier.run(); + } + } + + /** Stable namespace prevents accidental collision with ordinary producer keys. */ + static String key(String requestId) + { + return ("retained:" + requestId); + } + + + /** Shared stable intent syntax supports owner namespaces such as echo:scan-id. */ + private static void requestIdentity(String value) + { + if(value == null || !value.matches("[A-Za-z0-9._:-]{1,128}")) + { + throw new PrintingException(400, "Invalid retained print request identity"); + } + } + + /** Decode only a server-built accepted proof, never a wire job or caller payload. */ + private static Acceptance acceptance(JSONObject row) + { + JSONObject binding = row.getJSONObject("binding"); + JSONObject source = binding.getJSONObject("source"); + return (new Acceptance(binding.getString("requestId"), row.getString("jobId"), row.getString("acceptedEventId"), + Instant.parse(row.getString("acceptedAt")), binding.getString("tenantId"), binding.getString("actorId"), + new SourceReference(source.getString("owner"), source.getString("opaqueReference"), source.getString("sha256"), + source.getLong("sizeBytes"), source.getString("mediaType")), binding.getString("deviceId"), binding.getString("printerId"), binding.getString("printerRecordId"), + binding.getString("title"), binding.getJSONObject("options").toString())); + } + + /** Reject malformed Unicode, controls and oversized identifiers without logging values. */ + private static void text(String value, int maximum) + { + if(value == null || value.isBlank() || value.length() > maximum) + { + throw new PrintingException(400, "Invalid retained print identifier"); + } + for(int i = 0; i < value.length(); i++) + { + char c = value.charAt(i); + if(Character.isISOControl(c) || Character.isLowSurrogate(c) + || (Character.isHighSurrogate(c) && (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i))))) + { + throw new PrintingException(400, "Invalid retained print identifier"); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java new file mode 100644 index 0000000..2c0c4f0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java @@ -0,0 +1,54 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Trusted host adapter to an independent retained-document owner. Both methods + ** must authorize the CURRENT native actor and exact eligible source. A locator + ** is correlation, never permission. Do not reconstruct or replace sessions. + *******************************************************************************/ +public interface RetainedPrintSourceAuthority +{ + /** + ** Resolve a canonical immutable version under the owner's current admission policy. + ** The opaque reference binds source association, version, generation request, + ** initiating actor and tenant; printing adds no release or quarantine override. + ** Resolution is read-only: it must not generate, dispatch, release, or reconcile work. + */ + RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException; + + /** Read only that version, with current owner authorization and bounded bytes. */ + byte[] read(RetainedPrintFacade.SourceReference source, String requestId) throws QException; + + /** + ** Authorize exposing an accepted source's metadata to the current reporter. + ** This grants neither byte access nor impersonation of the initiating actor. + ** Owners may apply their observation policy independently of release state. + */ + default void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException + { + throw new QException("Retained source observation is not configured"); + } +} diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index 33ce14d..560afc4 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -207,6 +207,7 @@ components: enum: - "REFRESH_INVENTORY" - "CANCEL_SPOOL" + - "printer-media-v1" type: "string" type: "array" lastAppliedRevision: @@ -416,6 +417,145 @@ components: required: - "jobs" type: "object" + PrintingMediaProfileV1: + properties: + id: + maxLength: 128 + type: "string" + revision: + maxLength: 64 + type: "string" + name: + maxLength: 512 + type: "string" + widthMicrons: + type: "integer" + heightMicrons: + type: "integer" + dpiX: + type: "integer" + dpiY: + type: "integer" + source: + enum: + - "WINDOWS_QUEUE_DEFAULT" + type: "string" + applyMode: + enum: + - "DRIVER_DEFAULT" + type: "string" + required: + - "id" + - "revision" + - "name" + - "widthMicrons" + - "heightMicrons" + - "dpiX" + - "dpiY" + - "source" + - "applyMode" + type: "object" + PrintingMediaSelectionV1: + properties: + id: + maxLength: 128 + type: "string" + revision: + maxLength: 64 + type: "string" + required: + - "id" + - "revision" + type: "object" + PrintingOperatorCatalogV1: + properties: + printers: + items: + $ref: "#/components/schemas/PrintingOperatorPrinterV1" + type: "array" + nextCursor: + maxLength: 200 + nullable: true + type: "string" + required: + - "printers" + - "nextCursor" + type: "object" + PrintingOperatorPrinterV1: + properties: + printerRowId: + maxLength: 128 + type: "string" + deviceId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + name: + maxLength: 1024 + type: "string" + deviceName: + maxLength: 1024 + type: "string" + description: + maxLength: 4096 + type: "string" + location: + maxLength: 512 + type: "string" + status: + maxLength: 40 + type: "string" + formats: + items: + enum: + - "PDF" + - "ZPL" + type: "string" + type: "array" + mediaProfiles: + items: + $ref: "#/components/schemas/PrintingMediaProfileV1" + type: "array" + required: + - "printerRowId" + - "deviceId" + - "printerId" + - "name" + - "deviceName" + - "status" + - "formats" + - "mediaProfiles" + type: "object" + PrintingOperatorSubmitV1: + properties: + requestId: + maxLength: 128 + type: "string" + printerRowId: + maxLength: 128 + type: "string" + title: + maxLength: 1024 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + contentBase64: + maxLength: 2147483647 + type: "string" + options: + $ref: "#/components/schemas/PrintingOptionsV1" + required: + - "requestId" + - "printerRowId" + - "title" + - "format" + - "contentBase64" + type: "object" PrintingOptionsV1: properties: copies: @@ -425,6 +565,8 @@ components: - "ACTUAL_SIZE" - "FIT" type: "string" + mediaProfile: + $ref: "#/components/schemas/PrintingMediaSelectionV1" paperWidthMm: type: "number" paperHeightMm: @@ -459,6 +601,10 @@ components: - "ZPL" type: "string" type: "array" + mediaProfiles: + items: + $ref: "#/components/schemas/PrintingMediaProfileV1" + type: "array" displayName: maxLength: 512 type: "string" @@ -605,9 +751,10 @@ info: contact: email: "contact@kingsrook.com" description: "Native current-user printing with secondary device proof, device-control-v1\ - \ and realtime-v1; record, table-list and text printing processes." + \ and realtime-v1; record, table-list and text printing processes; render to PDF\ + \ with the app header and footer; least-privilege operator catalog and printer-media-v1." title: "QQQ Middleware API extension: printing" - version: "2.1.0" + version: "2.3.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: @@ -2071,6 +2218,231 @@ paths: summary: "Printing Command" tags: - "printing" + /qqq/v1/qbit/printing/operator/printers: + get: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorCatalog" + parameters: + - description: "1 through 200; default 50." + in: "query" + name: "limit" + schema: + type: "integer" + - in: "query" + name: "after" + schema: + maxLength: 200 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingOperatorCatalogV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing OperatorCatalog" + tags: + - "printing" + /qqq/v1/qbit/printing/operator/jobs: + post: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorSubmit" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingOperatorSubmitV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing OperatorSubmit" + tags: + - "printing" + /qqq/v1/qbit/printing/operator/requests/{requestId}: + get: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorRequest" + parameters: + - in: "path" + name: "requestId" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing OperatorRequest" + tags: + - "printing" servers: - description: "The QQQ server that serves this document (paths include /qqq/v1)." url: "/" diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java index c716a82..9fe2bca 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java @@ -138,7 +138,8 @@ class PrintingListProcessesTest assertTrue(text.indexOf("Hopper") < text.indexOf("Lovelace"), text); assertTrue(text.contains("all 2 rows"), text); assertTrue(text.contains("sort Last name"), text); - assertTrue(text.contains("Person - printed "), "the footer names the table, time and user"); + assertTrue(text.contains("No filter - sort: Last name"), "the footer note carries the filter and sort"); + assertTrue(text.contains("QQQ printing"), "the default footer"); assertTrue(text.contains("2 rows"), text); assertTrue(fixture.service().jobs().getJSONObject(0).getString("title").startsWith("Person")); } @@ -283,7 +284,7 @@ class PrintingListProcessesTest /*************************************************************************** ** Run a process straight through with values. ***************************************************************************/ - private static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException + static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException { RunProcessInput input = new RunProcessInput().withProcessName(process).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); // as the UI API runs it: user input, so server-configured inputs would be refused @@ -318,7 +319,7 @@ class PrintingListProcessesTest ** The process fixture plus person (sections, pets), pet, a hidden and an ** opted-out table, on the memory backend. ***************************************************************************/ - private static PrintingProcessesTest.Fixture fixture() throws Exception + static PrintingProcessesTest.Fixture fixture() throws Exception { PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture(); QInstance instance = fixture.instance(); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java new file mode 100644 index 0000000..6786782 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java @@ -0,0 +1,378 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.nio.charset.StandardCharsets; +import java.sql.DriverManager; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QCredentialScope; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Real native post-Get hooks must not carry stale authority into trusted writes. + *******************************************************************************/ +class PrintingOperatorAuthorityTest +{ + private static final ThreadLocal<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", ""); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java new file mode 100644 index 0000000..48b179c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java @@ -0,0 +1,405 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QCredentialScope; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.Javalin; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Real native row security, operator isolation and immutable queue media facts. + *******************************************************************************/ +class PrintingOperatorTest +{ + @TempDir + Path directory; + + /*************************************************************************** + ** Each fixture owns its native context. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + /*************************************************************************** + ** Operators can submit and reconcile their own request, never manage devices. + ***************************************************************************/ + @Test + void operatorSubmitsWithoutAdminAndCannotEnumerateJobsOrOtherActors() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingControlAuthorization.principal()); + QueryInput denied = new QueryInput().withTableName(PrintingTables.JOB); + denied.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new QueryAction().execute(denied)); + JSONObject request = request(f.printerId(), "echo:scan-1"); + JSONObject accepted = PrintingOperatorAuthorization.submit(request); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId")); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:scan-1").getString("jobId")); + assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(128))).has("jobId")); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(129)))).getStatus()); + assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("title", "Changed"))).getStatus()); + QContext.getQSession().setUser(new QUser().withIdReference("another-operator")); + assertEquals(404, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1")).getStatus()); + assertFalse(accepted.getString("jobId").equals(PrintingOperatorAuthorization.submit(request).getString("jobId"))); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request)); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1")); + } + + + /*************************************************************************** + ** Tenant and parent device locks apply to catalog, submit and replay reads. + ***************************************************************************/ + @Test + void nativeLocksAndExposureFilterCatalogAndPreventForgedDestinations() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.foreignPrinterId(), "foreign"))); + f.service().enablePrinter(f.otherDevice(), "laser", false); + assertEquals(1, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.otherPrinterId(), "disabled"))); + f.instance().addSecurityKeyType(new QSecurityKeyType().withName("operator_device")); + f.instance().getTable(PrintingTables.DEVICE).withRecordSecurityLock(new RecordSecurityLock().withFieldName("id").withSecurityKeyType("operator_device")); + QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-a"), "operator_device", List.of(f.otherDevice()))); + assertEquals(0, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "locked"))); + QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-b"))); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.catalog(50, null)); + } + + + /*************************************************************************** + ** Changed default media refuses new work while exact accepted replay survives. + ***************************************************************************/ + @Test + void mediaFactsRoundTripAndRevisionChecksPrecedeAdmission() throws Exception + { + var f = PrintingProcessesTest.fixture(); + JSONObject media = media(); + f.service().inventory(f.device(), new JSONArray().put(printer(media))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject item = PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").toList().stream() + .map(value -> new JSONObject((Map<?, ?>) value)).filter(value -> value.getString("printerRowId").equals(f.printerId())).findFirst().orElseThrow(); + assertTrue(media.similar(item.getJSONArray("mediaProfiles").getJSONObject(0))); + JSONObject request = request(f.printerId(), "media").put("options", new JSONObject().put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64)))); + JSONObject accepted = PrintingOperatorAuthorization.submit(request); + f.service().inventory(f.device(), new JSONArray().put(printer(media.put("revision", "b".repeat(64))))); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId")); + assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("requestId", "stale"))).getStatus()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + assertEquals("a".repeat(64), f.service().getJob(accepted.getString("jobId")).getJSONObject("options").getJSONObject("mediaProfile").getString("revision")); + f.service().inventory(f.device(), new JSONArray().put(printer(null))); + assertEquals(0, f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").length()); + } + + + /*************************************************************************** + ** A malformed inventory rolls back rather than retaining invented stock data. + ***************************************************************************/ + @Test + void invalidMediaAndAmbiguousDimensionsAreRejected() throws Exception + { + var f = PrintingProcessesTest.fixture(); + for(JSONObject bad : List.of(media().put("widthMicrons", 0), media().put("dpiX", 9601), media().put("revision", "A".repeat(64)), media().put("applyMode", "CUSTOM"), media().put("heightMicrons", 152400.5))) + { + assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(bad)))); + } + assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(media()).put("mediaProfiles", new JSONArray().put(media()).put(media()))))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject request = request(f.printerId(), "bad").put("options", new JSONObject().put("paperWidthMm", 101.6).put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64)))); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request)).getStatus()); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** Additive facts remain compatible, but cannot broaden the stored selection. + ***************************************************************************/ + @Test + void futureReportedMetadataIsIgnoredButSelectionRemainsExact() throws Exception + { + var f = PrintingProcessesTest.fixture(); + f.service().inventory(f.device(), new JSONArray().put(printer(media().put("futureFact", new JSONObject().put("label", "future"))))); + JSONObject stored = f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").getJSONObject(0); + assertTrue(media().similar(stored)); + assertFalse(stored.has("futureFact")); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject selected = new JSONObject().put("id", "default").put("revision", "a".repeat(64)).put("futureFact", true); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "future") + .put("options", new JSONObject().put("mediaProfile", selected)))).getStatus()); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /** Both inventory and immutable selections use the shared 128-character ID bound. */ + @Test + void mediaIdentityBoundsMatchTheSharedWireContract() throws Exception + { + var f = PrintingProcessesTest.fixture(); + String maximum = "m".repeat(128); + f.service().inventory(f.device(), new JSONArray().put(printer(media().put("id", maximum)))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject options = new JSONObject().put("mediaProfile", new JSONObject().put("id", maximum).put("revision", "a".repeat(64))); + assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "maximum-media").put("options", options)).has("jobId")); + assertEquals(400, assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), + new JSONArray().put(printer(media().put("id", maximum + "m"))))).getStatus()); + options.getJSONObject("mediaProfile").put("id", maximum + "m"); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "oversized-media") + .put("options", options))).getStatus()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** The native backend process uses the same caller and stable request boundary. + ***************************************************************************/ + @Test + void processUsesOperatorPermissionAndReplaysSameRequest() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + RunBackendStepInput input = new RunBackendStepInput().withProcessName(PrintingOperatorProcesses.SUBMIT); + JSONObject request = request(f.printerId(), "process"); + request.keySet().forEach(key -> input.addValue(key, request.getString(key))); + RunBackendStepOutput first = new RunBackendStepOutput(); + new PrintingOperatorProcesses().run(input, first); + new PrintingOperatorProcesses().run(input, new RunBackendStepOutput()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + assertTrue(first.getValueString("job").contains("jobId")); + } + + + /*************************************************************************** + ** A read-only credential cannot bypass the domain write ceiling via processes. + ***************************************************************************/ + @Test + void scopeAndChannelCeilingsRemainEffective() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + QContext.getQSession().setCredentialScope(new QCredentialScope().withReadOnly(true).withProcesses(Set.of(PrintingOperatorProcesses.SUBMIT))); + assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "scope"))); + QContext.getQSession().setCredentialScope(null); + QContext.getQSession().setClientChannel("api/test"); + f.instance().getTable(PrintingTables.JOB).getPermissionRules().withDeniedClients(Set.of("api")); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "channel"))); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** Bounded pages retain cursor progress and old agents report no invented stock. + ***************************************************************************/ + @Test + void catalogPagesAndLegacyProfilesRemainHonest() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject first = PrintingOperatorAuthorization.catalog(1, null); + JSONObject last = PrintingOperatorAuthorization.catalog(1, first.getString("nextCursor")); + assertEquals(1, first.getJSONArray("printers").length()); + assertEquals(1, last.getJSONArray("printers").length()); + assertTrue(last.isNull("nextCursor")); + JSONObject row = first.getJSONArray("printers").getJSONObject(0); + assertEquals(0, row.getJSONArray("mediaProfiles").length()); + assertFalse(row.getString("name").isBlank()); + assertFalse(row.getString("deviceName").isBlank()); + assertFalse(row.getString("printerRowId").equals(last.getJSONArray("printers").getJSONObject(0).getString("printerRowId"))); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.catalog(201, null)).getStatus()); + } + + + /*************************************************************************** + ** Real native HTTP authenticates a non-admin and keeps mutation Origin checks. + ***************************************************************************/ + @Test + void nativeHttpOperatorBoundaryUsesStandardSessionAndReadOnlyLookup() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("operator-http").toString().replace('\\', '/'); + String password = "synthetic-operator-password-1234"; + PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", password)) + { + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = super.defineQInstance(); + instance.getAuthentication().withModuleCodeReference(new QCodeReference(OperatorAuthentication.class)); + return instance; + } + }; + PrintingService service = new PrintingService(new DriverDataSource(jdbc, "sa", ""), "demo"); + String device = service.enroll(service.createEnrollment("default", "Synthetic").put("deviceName", "Synthetic").put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(printer(media()))); + service.enablePrinter(device, "laser", true); + AtomicReference<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; + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java index 7821981..43f22a0 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java @@ -30,7 +30,10 @@ 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; @@ -53,6 +56,89 @@ 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 { diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java new file mode 100644 index 0000000..e013ca1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java @@ -0,0 +1,172 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.io.InputStream; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Save as PDF: the record sheet, table list and text page rendered for the + ** caller as a process download, with no printer and no print job. + *******************************************************************************/ +class PrintingRenderProcessesTest +{ + /*************************************************************************** + ** Clear the memory store and context. + ***************************************************************************/ + @AfterEach + void clear() + { + MemoryRecordStore.getInstance().reset(); + QContext.clear(); + } + + + + /*************************************************************************** + ** A record sheet comes back as a download named after the record. + ***************************************************************************/ + @Test + void recordSheetIsADownloadNotAJob() throws Exception + { + PrintingProcessesTest.Fixture fixture = PrintingListProcessesTest.fixture(); + RunProcessOutput output = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "1")); + assertEquals("Person-Ada-Lovelace.pdf", output.getValues().get("downloadFileName")); + String text = text(output); + assertTrue(text.contains("Person: Ada Lovelace") && text.contains("Pets (2)"), text); + assertEquals(0, fixture.service().jobs().length(), "saving does not queue a print job"); + } + + + + /*************************************************************************** + ** A table list and a text page render the same way their prints do. + ***************************************************************************/ + @Test + void tableListAndTextPage() throws Exception + { + PrintingListProcessesTest.fixture(); + RunProcessOutput list = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "person", "columns", "lastName,firstName", "scope", "ALL", + "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"lastName\",\"isAscending\":true}]}")); + assertEquals("Person.pdf", list.getValues().get("downloadFileName")); + String rows = text(list); + assertTrue(rows.contains("all 2 rows of 2") || rows.contains("all 2 rows"), rows); + assertTrue(rows.indexOf("Hopper") < rows.indexOf("Lovelace"), rows); + RunProcessOutput page = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home / Screen", "text", "| hello |")); + assertEquals("Home-Screen.pdf", page.getValues().get("downloadFileName")); + assertTrue(text(page).contains("| hello |")); + } + + + + /*************************************************************************** + ** Every document carries the app's header and footer from the client's + ** values: app, breadcrumb, server, user, printed-at; footer and page n of m; + ** a list adds its filter and sort above the footer. + ***************************************************************************/ + @Test + void headerAndFooterOnEveryDocument() throws Exception + { + PrintingListProcessesTest.fixture(); + Map<String, java.io.Serializable> chrome = Map.of("headerApp", "QQQ All", "headerTitle", "Sales › Customer › Ada Lovelace", "headerServer", "local (localhost:8078)", + "headerUser", "Admin", "headerPrintedAt", "2026-11-01 12:03", "footerLeft", "qqq ui 1.0.1 · local"); + for(Map<String, java.io.Serializable> values : java.util.List.<Map<String, java.io.Serializable>>of( + Map.of("kind", "RECORD", "tableName", "person", "recordId", "1"), + Map.of("kind", "LIST", "tableName", "person", "filterDescription", "lastName = Hopper", "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}]}"), + Map.of("kind", "TEXT", "title", "Home", "text", "hello"))) + { + java.util.HashMap<String, java.io.Serializable> all = new java.util.HashMap<>(values); + all.putAll(chrome); + String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, all)); + for(String expected : java.util.List.of("QQQ All", "Sales › Customer › Ada Lovelace", "local (localhost:8078) · Admin · 2026-11-01 12:03", "qqq ui 1.0.1 · local", "Page 1 of 1")) + { + assertTrue(text.contains(expected), values.get("kind") + " lacks " + expected + ":\n" + text); + } + if("LIST".equals(values.get("kind"))) + { + assertTrue(text.contains("Filter: lastName = Hopper - sort: Id descending"), text); + } + } + String defaults = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "2"))); + assertTrue(defaults.contains("Person › Grace Hopper") && defaults.contains("QQQ printing"), defaults); + } + + + + /*************************************************************************** + ** Content documents: each section's title as a heading and its lines, + ** long lines wrapped, no borders. + ***************************************************************************/ + @Test + void contentSectionsDocument() throws Exception + { + PrintingListProcessesTest.fixture(); + String sections = "[{\"title\":\"\",\"lines\":[\"Good morning, Admin\"]},{\"title\":\"Recently viewed\",\"lines\":[\"Ada Lovelace Customer · 1m ago\"]},{\"title\":\"Quick actions\",\"lines\":[\"" + "word ".repeat(60) + "\"]}]"; + String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", sections))); + assertTrue(text.contains("Good morning, Admin") && text.contains("Recently viewed") && text.contains("Quick actions"), text); + assertTrue(text.indexOf("Recently viewed") < text.indexOf("Ada Lovelace"), text); + assertTrue(!text.contains("│") && !text.contains("┌"), text); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "[]"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "{nope"))); + } + + + + /*************************************************************************** + ** Unknown kinds, missing records and opted-out tables are refused. + ***************************************************************************/ + @Test + void refusals() throws Exception + { + PrintingListProcessesTest.fixture(); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "NOPE"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "99"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "secretThing"))); + assertEquals("document.pdf", PrintingRenderProcesses.fileName("///")); + } + + + + /*************************************************************************** + ** The PDF text of a render's download. + ***************************************************************************/ + private static String text(RunProcessOutput output) throws Exception + { + try(InputStream stream = ProcessFileDownload.open(String.valueOf(output.getValues().get("serverFilePath"))); PDDocument document = PDDocument.load(stream)) + { + return (new PDFTextStripper().getText(document)); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java index f40c7f3..dae7c5b 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java @@ -67,7 +67,14 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable /** Reconcile after the connection's subscription is already registered. */ void initialize() throws Exception { - send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL")) + initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL")); + } + + + /** Explicit capabilities permit downgrade/reconnect qualification without changing old clients. */ + void initialize(JSONArray capabilities) throws Exception + { + send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", capabilities) .put("lastAppliedRevision", 0).put("lastSettingsRevision", 0).put("limits", PrintingRealtimePolicy.defaults().toJson(0))); JSONObject welcome = take("WELCOME").getJSONObject("payload"); apply(welcome.getJSONObject("desiredState").getLong("revision"), welcome.getJSONObject("settings").getLong("revision")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index c31bef0..570231e 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -59,9 +59,9 @@ class PrintingUiApiContractTest assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 2, "mvnw.cmd -Dtest=PrintingUiApiContractTest -Dprinting.openapi.update=true test")); var model = version.generateExtensionOpenAPIModel("qqq", extension); - assertEquals(17, model.getPaths().size()); + assertEquals(20, model.getPaths().size()); assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); - assertEquals("2.1.0", extension.getSemanticVersion()); + assertEquals("2.3.0", extension.getSemanticVersion()); assertTrue(generated.contains("X-QQQ-Device-Token")); assertTrue(generated.contains("application/octet-stream")); assertTrue(generated.contains("principalId")); diff --git a/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java new file mode 100644 index 0000000..30c9ef0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java @@ -0,0 +1,713 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** Actual owner transaction, native authorization and migration; no device output. + *******************************************************************************/ +class RetainedPrintFacadeTest +{ + private final RetainedPrintFacade facade = new RetainedPrintFacade(); + private final byte[] bytes = "%PDF-1.4\nretained fixture".getBytes(StandardCharsets.US_ASCII); + private final AtomicInteger reads = new AtomicInteger(); + private byte[] readOverride; + private long declaredSize; + private String opaque = "immutable-version-1"; + private boolean observationAllowed = true; + private Runnable onObservation = () -> + { + // No default callback side effects. + }; + private Runnable onRead = () -> + { + // No default callback side effects. + }; + + /** Native row metadata and real H2 share the printing service database. */ + private PrintingProcessesTest.Fixture fixture() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + config(fixture).withRetainedSourceAuthority(new RetainedPrintSourceAuthority() + { + @Override + public RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException + { + if(!"source-1".equals(locator)) + { + throw new QException("Source denied"); + } + try + { + return new RetainedPrintFacade.SourceReference("test-owner", opaque, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + declaredSize == 0 ? bytes.length : declaredSize, "application/pdf"); + } + catch(java.security.NoSuchAlgorithmException exception) + { + throw new QException("Fixture hash failed", exception); + } + } + + @Override + public byte[] read(RetainedPrintFacade.SourceReference source, String requestId) + { + reads.incrementAndGet(); + onRead.run(); + return readOverride == null ? bytes.clone() : readOverride.clone(); + } + + @Override + public void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException + { + if(!observationAllowed || !opaque.equals(source.opaqueReference())) + { + throw new QException("Current owner observation denied"); + } + onObservation.run(); + } + }); + return fixture; + } + + /** Resolve the production configured integration, not an invocation-supplied authority. */ + private PrintingQBitConfig config(PrintingProcessesTest.Fixture fixture) + { + return fixture.instance().getQBits().values().stream().map(bit -> bit.getConfig()).filter(PrintingQBitConfig.class::isInstance) + .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow(); + } + + /** Current synthetic native administrator; no arbitrary caller actor field exists. */ + private QSession session(String actor) + { + return new QSession().withUser(new QUser().withIdReference(actor)).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a"); + } + + /** Same stable request in every replay. */ + private RetainedPrintFacade.Request request(PrintingProcessesTest.Fixture fixture) + { + return new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{}"); + } + + /** Independent SQL connection observes actual committed storage. */ + private Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception + { + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + return DriverManager.getConnection(backend.getJdbcUrl(), "sa", ""); + } + + /** Read only a fixed test-owned table count. */ + private int count(PrintingProcessesTest.Fixture fixture, String table) throws Exception + { + try(Connection connection = connection(fixture); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM " + table)) + { + rows.next(); + return rows.getInt(1); + } + } + + /** Replay and lost-response lookup preserve complete binding without another byte read. */ + @Test + void acceptedReplayAndLookupReturnSameCommittedProof() throws Exception + { + var fixture = fixture(); + assertNull(facade.find(request(fixture))); + var accepted = facade.submit(request(fixture)); + assertEquals("operator", accepted.actorId()); + assertEquals("Retained document", accepted.title()); + assertEquals(1, new JSONObject(accepted.optionsJson()).getInt("copies")); + assertEquals(opaque, accepted.source().opaqueReference()); + assertEquals(accepted, facade.submit(request(fixture))); + assertEquals(accepted, facade.find(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + assertEquals("QUEUED", fixture.service().getJob(accepted.jobId()).getString("status")); + } + + /** A committed transaction with a lost acknowledgment is recovered without a second job. */ + @Test + void commitThenThrowRecoversExactAcceptance() throws Exception + { + var fixture = fixture(); + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", ""); + var fail = new java.util.concurrent.atomic.AtomicBoolean(true); + javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { javax.sql.DataSource.class }, (proxy, method, args) -> + { + try + { + Object result = method.invoke(actual, args); + if(result instanceof Connection connection) + { + return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (connectionProxy, operation, values) -> + { + try + { + Object returned = operation.invoke(connection, values); + if("commit".equals(operation.getName()) && fail.get()) + { + try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM printing_job WHERE accepted_event_id IS NOT NULL")) + { + rows.next(); + if(rows.getInt(1) == 1 && fail.compareAndSet(true, false)) + { + throw new java.sql.SQLException("Synthetic lost commit acknowledgment", "08007"); + } + } + } + return returned; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + } + return result; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + config(fixture).withService(new PrintingService(source, "tenant-a")); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + var recovered = facade.find(request(fixture)); + assertNotNull(recovered); + assertEquals(recovered, facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + } + + /** Unknown owner configuration, parent denial, tenant denial and callback revocation do not enqueue. */ + @Test + void currentAuthorityIsRequiredBeforeAndAfterOwnerCallbacks() throws Exception + { + var unconfigured = PrintingProcessesTest.fixture(); + assertThrows(QException.class, () -> facade.submit(request(unconfigured))); + var fixture = fixture(); + assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("bad", "denied", fixture.printerId(), "Test", "{}"))); + assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("foreign", "source-1", fixture.foreignPrinterId(), "Test", "{}"))); + onRead = () -> QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + + /** Read-only credentials and write-denied channels permit lookup, never queue mutation. */ + @Test + void nativeMutationCeilingsCannotBeBypassedByAdministratorRole() throws Exception + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true)); + assertEquals(accepted, facade.find(request(fixture))); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + QContext.getQSession().withCredentialScope(null).withClientChannel("api"); + com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData.ofOrWithNew(fixture.instance()).withDeniedWritePattern("api", PrintingTables.JOB); + assertEquals(accepted, facade.find(request(fixture))); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + } + + /** Initialization upgrades the original 300-character column; spaces and Unicode remain exact. */ + @Test + void titleAt1024CharactersIsStoredAndReplayedWithoutNormalization() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.execute("ALTER TABLE printing_job ALTER COLUMN title VARCHAR(300)"); + } + fixture.service().initialize(); + String title = " " + "é".repeat(1020) + " "; + var request = new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title, "{}"); + var accepted = facade.submit(request); + assertEquals(title, fixture.service().getJob(accepted.jobId()).getString("title")); + assertEquals(accepted, facade.find(request)); + assertEquals(accepted, facade.submit(request)); + assertThrows(PrintingException.class, () -> new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title + "x", "{}")); + } + + /** Current destination approval is checked again after the retained owner read. */ + @Test + void printerDisabledDuringOwnerReadCannotBeAccepted() throws Exception + { + var fixture = fixture(); + onRead = () -> + { + try + { + fixture.service().enablePrinter(fixture.device(), "laser", false); + } + catch(java.sql.SQLException exception) + { + throw new IllegalStateException(exception); + } + }; + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + + /** Stable key cannot be rebound by another actor, version, title, options or destination. */ + @Test + void changedIntentAndUnboundCollisionRefuse() throws Exception + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + opaque = "immutable-version-2"; + assertThrows(PrintingException.class, () -> facade.submit(request(fixture))); + opaque = "immutable-version-1"; + QContext.init(fixture.instance(), session("another-operator")); + assertThrows(PrintingException.class, () -> facade.submit(request(fixture))); + QContext.init(fixture.instance(), session("operator")); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Changed", "{}"))); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.otherPrinterId(), "Retained document", "{}"))); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{\"copies\":2}"))); + assertEquals(accepted, facade.find(request(fixture))); + JSONObject raw = new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "Raw") + .put("format", "PDF").put("contentBase64", java.util.Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", "retained:raw-key"); + fixture.service().submit(raw); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("raw-key", "source-1", fixture.printerId(), "Raw", "{}"))); + raw.put("idempotencyKey", "retained:intent-1"); + assertThrows(PrintingException.class, () -> fixture.service().submit(raw)); + } + + /** A real database refusal of acceptance evidence rolls the actual job insert back. */ + @Test + void failedAcceptanceEventRollsBackJob() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.execute("ALTER TABLE printing_event ADD CONSTRAINT refuse_acceptance CHECK (event_origin IS NULL OR event_origin <> 'SERVER_ACCEPTANCE')"); + } + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + assertNull(facade.find(request(fixture))); + } + + /** Actual transaction cleanup preserves the owner failure, including reused exception identity. */ + @Test + void rollbackFailureNeverMasksOwnerFailure() throws Exception + { + for(boolean sameFailure : List.of(false, true)) + { + var fixture = fixture(); + PrintingException original = new PrintingException(403, "Synthetic owner denial"); + Exception cleanup = sameFailure ? original : new java.sql.SQLException("Synthetic rollback failure"); + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", ""); + javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { javax.sql.DataSource.class }, (proxy, method, args) -> + { + try + { + Object result = method.invoke(actual, args); + if(result instanceof Connection connection) + { + return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (connectionProxy, operation, values) -> + { + try + { + Object returned = operation.invoke(connection, values); + if("rollback".equals(operation.getName())) + { + throw cleanup; + } + return returned; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + } + return result; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + config(fixture).withService(new PrintingService(source, "tenant-a")); + onRead = () -> + { + throw original; + }; + org.junit.jupiter.api.Assertions.assertSame(original, assertThrows(PrintingException.class, () -> facade.submit(request(fixture)))); + assertEquals(sameFailure ? 0 : 1, original.getSuppressed().length); + if(!sameFailure) + { + org.junit.jupiter.api.Assertions.assertSame(cleanup, original.getSuppressed()[0]); + } + assertEquals(0, count(fixture, "printing_job")); + } + } + + /** Competing callers share the existing tenant admission lock and unique request identity. */ + @Test + void concurrentIdenticalRequestsShareOneJobAndAcceptance() throws Exception + { + var fixture = fixture(); + try(var executor = Executors.newFixedThreadPool(2)) + { + java.util.concurrent.Callable<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(); + } +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..112a4bb --- /dev/null +++ b/uv.lock @@ -0,0 +1,43 @@ +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" }, +]