Preserve both pre-cutoff dependency branch histories #13
No files matched your search
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": "3.13",
|
||||
"include": [
|
||||
"scripts/*.py"
|
||||
],
|
||||
"reportMissingTypeStubs": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
"reportImplicitOverride": "error",
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "..",
|
||||
"venv": ".venv"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
|
||||
SCRIPT_PATTERN = ".github/scripts/*.py"
|
||||
PYRIGHT_CONFIG = ".github"
|
||||
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
|
||||
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
|
||||
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
|
||||
|
||||
|
||||
def tracked_files(root: Path) -> list[str]:
|
||||
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
|
||||
# Fixed Git inventory operation; the root is an argument, never shell input.
|
||||
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
return [name for name in output.split("\u0000") if name]
|
||||
|
||||
|
||||
def python_scope(tracked: list[str]) -> list[str]:
|
||||
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
|
||||
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
|
||||
if outside:
|
||||
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned Python scripts selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
|
||||
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
|
||||
tsconfig = root / "tsconfig.json"
|
||||
if not tsconfig.is_file():
|
||||
if candidates:
|
||||
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
|
||||
return []
|
||||
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
|
||||
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
|
||||
if outside:
|
||||
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned JavaScript sources selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def run_pyright(root: Path, config: str) -> dict[str, Any]:
|
||||
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
raise ValueError("Node.js is required to run the locked Pyright")
|
||||
if not PYRIGHT_ENTRY.is_file():
|
||||
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
|
||||
# Resolved Node and locked entry script; the config is an argument, never shell input.
|
||||
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
|
||||
try:
|
||||
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
|
||||
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
|
||||
report["exitCode"] = completed.returncode
|
||||
except (ValueError, KeyError) as error:
|
||||
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
|
||||
return report
|
||||
|
||||
|
||||
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
|
||||
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
|
||||
summary = report["summary"]
|
||||
analyzed = summary["filesAnalyzed"]
|
||||
if analyzed == 0 or analyzed != expected:
|
||||
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
|
||||
if summary["errorCount"] or summary["warningCount"]:
|
||||
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
|
||||
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
|
||||
if report["exitCode"] != 0:
|
||||
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
|
||||
|
||||
|
||||
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
|
||||
"""Run Ruff on the explicit source list so no selected file can be skipped."""
|
||||
# Fixed interpreter module invocation; paths are arguments, never shell input.
|
||||
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
|
||||
raise ValueError("Ruff " + " ".join(arguments) + " failed")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tracked = tracked_files(root)
|
||||
sources = python_scope(tracked)
|
||||
javascript = javascript_scope(root, tracked)
|
||||
run_ruff(root, ["format", "--check"], sources)
|
||||
run_ruff(root, ["check"], sources)
|
||||
report = run_pyright(root, PYRIGHT_CONFIG)
|
||||
check_pyright_report(report, len(sources))
|
||||
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
def leading_comments(text: str, style: str) -> str:
|
||||
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
|
||||
"""Read comments only, preserving required interpreter/XML directive ordering."""
|
||||
text = text.removeprefix("\ufeff")
|
||||
if text.startswith("<?xml"):
|
||||
declaration = re.match(r"<\?xml[^?]*\?>", text)
|
||||
if style != "xml" or declaration is None:
|
||||
raise ValueError("Invalid XML declaration")
|
||||
text = text[declaration.end():]
|
||||
text = text[declaration.end() :]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
@@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str:
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments = []
|
||||
comments: list[str] = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
@@ -46,11 +46,9 @@ def leading_comments(text: str, style: str) -> str:
|
||||
elif style == "slash" and text.startswith("//"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[2:])
|
||||
elif ((style == "slash" and text.startswith("/*"))
|
||||
or (style == "xml" and text.startswith("<!--"))
|
||||
or (style == "powershell" and text.startswith("<#"))):
|
||||
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening):].partition(closing)
|
||||
body, separator, rest = text[len(opening) :].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
@@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
|
||||
for notice in approved:
|
||||
holder = re.escape(notice["holder"])
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
if re.search(copyright_pattern, comments) is None:
|
||||
continue
|
||||
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
|
||||
if spdx and any(value != notice["license"] for value in spdx):
|
||||
continue
|
||||
apache = all(phrase in comments for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
))
|
||||
apache = all(
|
||||
phrase in comments
|
||||
for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
)
|
||||
)
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
@@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
|
||||
failures = []
|
||||
# Fixed Git inventory operation; paths are arguments, never shell input.
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
failures: list[str] = []
|
||||
count = 0
|
||||
for name in tracked:
|
||||
if not name or not selected(name, policy):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -11,7 +11,6 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
HEADERS = importlib.util.module_from_spec(SPEC)
|
||||
@@ -32,8 +31,7 @@ limitations under the License."""
|
||||
|
||||
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
|
||||
"""Use the production styles with a small explicit owned fixture scope."""
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
|
||||
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
@@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
|
||||
"""Accept a hyphen or en dash between years and reject any other separator."""
|
||||
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
|
||||
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
|
||||
with self.subTest(separator=separator):
|
||||
if accepted:
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_required_first_lines_and_bom(self) -> None:
|
||||
"""Accept shebangs, PowerShell requirements and XML declarations first."""
|
||||
for name, prefix, body in (
|
||||
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
|
||||
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
|
||||
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
@@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase):
|
||||
for name, text in (
|
||||
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
|
||||
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
|
||||
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
|
||||
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_code_strings_and_late_comments_do_not_count(self) -> None:
|
||||
"""Reject docstrings and comments after executable declarations."""
|
||||
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
|
||||
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
|
||||
"""Notice text alone is insufficient without a complete language comment."""
|
||||
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
|
||||
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase):
|
||||
|
||||
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
|
||||
"""Do not conflate repository licenses or accept conflicting identifiers."""
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
|
||||
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
@@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase):
|
||||
def test_java_requires_full_apache_block(self) -> None:
|
||||
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
|
||||
APACHE.replace("limitations under the License.", "")):
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
@@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase):
|
||||
"""New scripts enter the guard; data, generated and upstream files do not."""
|
||||
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
|
||||
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
|
||||
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
|
||||
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
@@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase):
|
||||
"""A tracked new source fails while untracked generated output is ignored."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
|
||||
root.joinpath("src").mkdir()
|
||||
source = root / "src/a.py"
|
||||
source.write_text("print(1)", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
|
||||
with self.assertRaisesRegex(ValueError, "src/a.py"):
|
||||
HEADERS.check_repository(root, fixture_policy())
|
||||
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,3 +12,9 @@ dist/
|
||||
*.iml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local script-quality environments and tool caches.
|
||||
.venv/
|
||||
node_modules/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
+38
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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:<scanId>`.
|
||||
`submit` derives the signed-in native actor and returns an immutable
|
||||
`Acceptance`; it includes the exact source, job/event identities, tenant,
|
||||
initiating actor, destination, title and normalized options JSON, including any
|
||||
`mediaProfile` ID/revision. `find` authorizes the same complete request and
|
||||
returns that acceptance, null for observed absence, or a conflict for an
|
||||
unresolved upload reservation. Reuse the same request identity after a lost
|
||||
response. Never convert an unavailable lookup into absence or create another
|
||||
intent automatically.
|
||||
|
||||
The host-configured `RetainedPrintSourceAuthority.resolve(locator, requestId)`
|
||||
must authorize the current native actor, source association and currently
|
||||
eligible immutable version. It returns `SourceReference(owner, opaqueReference,
|
||||
sha256, sizeBytes, mediaType)`. `opaqueReference` is at most 4000 Java
|
||||
characters, stable and canonical under that owner's rules; printing does not
|
||||
parse owner-specific fields. The optional Record Files adapter must encode its
|
||||
complete version/source/generation-request/initiating-actor/tenant tuple and
|
||||
compare generation's committed receipt. Accepted MIME types are
|
||||
`application/pdf` and `application/vnd.zebra-zpl`; the current configured
|
||||
PDF/ZPL limits apply before the byte read, within the existing integer-length
|
||||
storage ceiling. `read(reference, requestId)` must enforce the same current
|
||||
permissions and return only those exact bytes. Hash/length verification remains
|
||||
in printing's existing submission path. Do not supply arbitrary source
|
||||
authorities per request or reconstruct a user session.
|
||||
|
||||
Source admission remains the owner's policy. An explicitly configured published
|
||||
template may qualify generated output without a per-label release dialog;
|
||||
external-upload quarantine is unchanged. Printing performs no automatic release
|
||||
and creates no human-release audit. `resolve` must be read-only and must not
|
||||
invoke a generation reconciliation operation that can generate or dispatch work.
|
||||
|
||||
Source/destination checks surround owner callbacks and precede acceptance.
|
||||
Submission refuses an ambient QQQ transaction: queue acceptance is independently
|
||||
committed and cannot be rolled back by a caller's transaction. Submission also
|
||||
enforces the native job-table credential and channel mutation ceilings; lookup
|
||||
uses the corresponding read ceilings without granting generic table access.
|
||||
`find` calls no byte reader, submission or event writer, and its option
|
||||
normalization cannot initialize settings. It reauthorizes the source and
|
||||
destination records, then compares the exact accepted intent; a later exposure,
|
||||
media inventory or dispatch-limit change does not reinterpret that historical
|
||||
intent. A later permission loss may deny returning a receipt whose job already
|
||||
committed; it never authorizes a replacement job.
|
||||
|
||||
## Persistence and replay
|
||||
|
||||
Existing `printing_job` gains nullable `retained_actor_id`,
|
||||
`retained_binding_json`, and `accepted_event_id`. Existing `printing_event`
|
||||
gains nullable `event_origin`. `initialize` applies additive upgrades; old
|
||||
jobs/events remain unbound/unclassified. None of these internal fields are added
|
||||
to the device `JOB_COLUMNS` DTO or native table metadata.
|
||||
|
||||
Submission and exact lookup both use the existing tenant admission lock, so an
|
||||
in-flight inline acceptance cannot be mistaken for absence. The unique
|
||||
`(tenant_id, idempotency_key)` serialize submissions. Retained keys use
|
||||
`retained:` plus the request ID. Ordinary/bound collisions refuse. Complete
|
||||
normalized intent includes actor, owner reference, content hash/length/media,
|
||||
destination, title, options and tenant. The existing storage reservation/orphan
|
||||
path is reused; it may commit an upload reservation before writing object
|
||||
storage. The final job, retained binding, and server acceptance event commit
|
||||
together. Failed acceptance-event insertion rolls back the job. Rollback or
|
||||
orphan-cleanup failures are suppressed onto the original failure rather than
|
||||
replacing it. Existing initialization widens legacy 300-character titles to
|
||||
1024; the bound title preserves exact whitespace and Unicode and is compared on
|
||||
replay. Acceptance-event JSON has `type=ACCEPTED`, `origin=SERVER_ACCEPTANCE`,
|
||||
`status=QUEUED`; it does not claim device receipt or physical output.
|
||||
|
||||
This is atomic acceptance binding inside printing, not a distributed transaction
|
||||
over the retained owner's authorization database. Deployment still owns durable
|
||||
database/storage settings, bounded provider calls and current session policy.
|
||||
|
||||
## Committed observations
|
||||
|
||||
`observation(requestId, eventId)` reads only an exact committed event joined to
|
||||
the accepted job. New ordinary device events are stamped `DEVICE_OBSERVATION`;
|
||||
pre-upgrade unclassified events and server acceptance events cannot be returned
|
||||
as device evidence. The current native operator must have current device/printer
|
||||
row authority and satisfy the job credential/channel read ceiling. Before
|
||||
returning any source metadata, printing calls the configured owner's
|
||||
`authorizeObservation(source, requestId)` under the current reporter session,
|
||||
then rechecks the current ceiling and destination authority. Its default
|
||||
implementation refuses; adapters must explicitly implement their current
|
||||
source-observation policy. This callback does not grant content access or
|
||||
impersonate the initiating actor. This is an operator-authorized observation
|
||||
lookup, not device enrollment or permission to forge reporter identity. The
|
||||
optional RF authority adapter must preserve initiating actor separately from
|
||||
current reporting actor and map only supported stored states. Neither `SPOOLED`
|
||||
nor `SPOOLER_COMPLETED` proves physical output.
|
||||
|
||||
## Verification scope
|
||||
|
||||
The tests use real H2 transactions and native QQQ row authorization with
|
||||
synthetic printers. They never contact a device or emit physical output.
|
||||
Coverage includes commit-then-throw acknowledgment loss, concurrent exact
|
||||
replay, held-admission-lock lookup, mutation ceilings, owner observation
|
||||
refusal, callback revocation, exact content hash/length, configured document
|
||||
bounds, title upgrade/replay, read-only historical lookup and original-error
|
||||
preservation when rollback fails. Qualification of this adopted source is
|
||||
recorded in the session notes; the older peer candidate's test result is not
|
||||
evidence for this revision. Cross-QBit integration remains a separate gate. No
|
||||
release, install, shared-cache substitution or runtime migration has occurred.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
**
|
||||
** <app> <server> · <user> · <printed at>
|
||||
** <title / breadcrumb>
|
||||
** ...
|
||||
** <footer note: a grid's filter and sort>
|
||||
** <footer: "qqq ui 1.0.0 · server"> Page n of m
|
||||
**
|
||||
** Clients send the fields as process values (headerApp, headerTitle,
|
||||
** headerServer, headerUser, headerPrintedAt, footerLeft, footerNote) so the
|
||||
** text matches what the user saw (their date format, the app's name, the
|
||||
** breadcrumb). Missing fields fall back to the instance's branding name, the
|
||||
** session user, the current instant and "QQQ printing".
|
||||
*******************************************************************************/
|
||||
public record PrintingChrome(String app, String title, String server, String user, String printedAt, String footerLeft, String footerNote)
|
||||
{
|
||||
/***************************************************************************
|
||||
** The fields from process values, with server-side defaults.
|
||||
***************************************************************************/
|
||||
public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle)
|
||||
{
|
||||
return (new PrintingChrome(
|
||||
value(input, "headerApp", defaultApp()),
|
||||
value(input, "headerTitle", fallbackTitle),
|
||||
value(input, "headerServer", ""),
|
||||
value(input, "headerUser", defaultUser()),
|
||||
value(input, "headerPrintedAt", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()),
|
||||
value(input, "footerLeft", "QQQ printing"),
|
||||
value(input, "footerNote", "")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Defaults only (documents rendered outside a process).
|
||||
***************************************************************************/
|
||||
public static PrintingChrome defaults(String title)
|
||||
{
|
||||
return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same chrome with a footer note (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
public PrintingChrome withFooterNote(String note)
|
||||
{
|
||||
return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same chrome with this title when it has none.
|
||||
***************************************************************************/
|
||||
public PrintingChrome withTitleIfBlank(String fallback)
|
||||
{
|
||||
return (title != null && !title.isBlank() ? this : new PrintingChrome(app, fallback, server, user, printedAt, footerLeft, footerNote));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The right side of the first header line.
|
||||
***************************************************************************/
|
||||
String headerRight()
|
||||
{
|
||||
StringBuilder out = new StringBuilder();
|
||||
for(String part : new String[] {server, user, printedAt})
|
||||
{
|
||||
if(part != null && !part.isBlank())
|
||||
{
|
||||
out.append(out.isEmpty() ? "" : " · ").append(part);
|
||||
}
|
||||
}
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A string value, or the default when absent or blank (bounded length).
|
||||
***************************************************************************/
|
||||
private static String value(RunBackendStepInput input, String name, String fallback)
|
||||
{
|
||||
String value = input == null ? null : input.getValueString(name);
|
||||
String chosen = value == null || value.isBlank() ? fallback : value;
|
||||
return (chosen == null ? "" : PrintingRecordDocuments.truncate(chosen.strip(), 300));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The instance's branding name, else "QQQ".
|
||||
***************************************************************************/
|
||||
private static String defaultApp()
|
||||
{
|
||||
try
|
||||
{
|
||||
var branding = QContext.getQInstance() == null ? null : QContext.getQInstance().getBranding();
|
||||
return (branding != null && branding.getAppName() != null ? branding.getAppName() : "QQQ");
|
||||
}
|
||||
catch(RuntimeException exception)
|
||||
{
|
||||
return ("QQQ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The session user's name, else blank.
|
||||
***************************************************************************/
|
||||
private static String defaultUser()
|
||||
{
|
||||
return (QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getFullName() == null ? "" : QContext.getQSession().getUser().getFullName());
|
||||
}
|
||||
}
|
||||
@@ -266,8 +266,8 @@ final class PrintingDeviceControls
|
||||
}
|
||||
String version = text(request, "agentVersion", 100);
|
||||
long applied = integer(request, "lastAppliedRevision");
|
||||
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -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") : ""));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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: "/"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user