Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e02dbfff0
|
||
|
|
2cec82bf4a | ||
|
|
088692e3ae | ||
|
|
f2505160c6
|
||
|
|
13d6ee1a23
|
||
|
|
64c9ebf0fa | ||
|
|
f80498513e
|
||
|
|
65e32f5ce9
|
||
|
|
5f022541b1 | ||
|
|
a514a70953 | ||
|
|
4eee28e9db
|
||
|
|
88f5fbb4b5
|
||
|
|
08ee0efa3d
|
||
|
|
1a0964ef23 | ||
|
|
02b922aa11 | ||
|
|
bfba557b66 | ||
|
|
3592decba8 | ||
|
|
2b2ad3346a | ||
|
|
ea3ba0d698 | ||
|
|
c84372deb2 |
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Bug report
|
||||
description: Report a reproducible printing QBit issue.
|
||||
title: 'bug: '
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Report a security vulnerability privately
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": "3.13",
|
||||
"include": [
|
||||
"scripts/*.py"
|
||||
],
|
||||
"reportMissingTypeStubs": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
"reportImplicitOverride": "error",
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "..",
|
||||
"venv": ".venv"
|
||||
}
|
||||
@@ -1,53 +1,108 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# 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)
|
||||
@@ -1,125 +1,173 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# 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)
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Check leading notices in explicitly owned, Git-tracked source families."""
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
"""Select owned paths, honoring documented exact/pattern exclusions first."""
|
||||
if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]):
|
||||
return False
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
|
||||
"""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() :]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
text = text.partition("\n")[2]
|
||||
while re.match(r"#Requires\b", text, re.IGNORECASE):
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments: list[str] = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
raise ValueError("Required declaration must precede the notice")
|
||||
if style in ("hash", "powershell") and text.startswith("#"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[1:])
|
||||
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("<#")):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening) :].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
text = rest
|
||||
else:
|
||||
break
|
||||
return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines())
|
||||
|
||||
|
||||
def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
"""Require an approved holder/license pair in real leading comment syntax."""
|
||||
suffix = Path(name).suffix
|
||||
style = policy["styles"].get(suffix)
|
||||
if Path(name).name.startswith("Dockerfile."):
|
||||
style = "hash"
|
||||
if style is None:
|
||||
raise ValueError("Selected path has no configured comment syntax")
|
||||
comments = leading_comments(text, style)
|
||||
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
|
||||
for notice in approved:
|
||||
holder = re.escape(notice["holder"])
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
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.",
|
||||
)
|
||||
)
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
return
|
||||
raise ValueError("Missing or mismatched leading copyright/license notice")
|
||||
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
# Fixed Git inventory operation; paths are arguments, never shell input.
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
failures: list[str] = []
|
||||
count = 0
|
||||
for name in tracked:
|
||||
if not name or not selected(name, policy):
|
||||
continue
|
||||
count += 1
|
||||
try:
|
||||
check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy)
|
||||
except (ValueError, OSError) as error:
|
||||
failures.append(f"{name}: {error}")
|
||||
if count == 0:
|
||||
raise ValueError("No owned source files selected")
|
||||
if failures:
|
||||
raise ValueError("\n".join(failures))
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the repository-specific reviewed scope and fail CI on a bad notice."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8"))
|
||||
count = check_repository(root, policy)
|
||||
print(f"Leading source notices verified: {count} tracked files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,105 +1,118 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# 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()
|
||||
@@ -1,89 +1,107 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# 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()
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Exercise notice parsing, provenance, scope and real Git inventory rejection."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
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)
|
||||
SPEC.loader.exec_module(HEADERS)
|
||||
POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8"))
|
||||
MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT"
|
||||
APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
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."""
|
||||
|
||||
|
||||
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/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
"""Ensure policy failures cannot pass through text-only copyright searches."""
|
||||
|
||||
def test_each_real_comment_syntax(self) -> None:
|
||||
"""Recognize hash, slash, block and XML notices in their own languages."""
|
||||
for name, text in (
|
||||
("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"),
|
||||
("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"),
|
||||
("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"),
|
||||
("src/a.xml", "<!--\n" + MIT + "\n-->\n<root/>"),
|
||||
("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"),
|
||||
):
|
||||
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.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy())
|
||||
|
||||
def test_misordered_required_first_lines_fail(self) -> None:
|
||||
"""A notice must not displace an interpreter or XML declaration."""
|
||||
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/>'),
|
||||
):
|
||||
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# ")):
|
||||
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 + '";'):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy())
|
||||
|
||||
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]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
def test_complete_legacy_apache_without_spdx(self) -> None:
|
||||
"""Retain valid legacy year ranges and complete Apache notices."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy)
|
||||
HEADERS.check_notice("src/a.xml", "<!--" + APACHE + "--><root/>", policy)
|
||||
|
||||
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.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
def test_virtual_agent_provenance_is_path_scoped(self) -> None:
|
||||
"""Only the owned virtual-agent family may use its existing QRun.IO holder."""
|
||||
policy = fixture_policy()
|
||||
policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]})
|
||||
text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/"
|
||||
HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy)
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.mjs", text, policy)
|
||||
|
||||
def test_current_scope_and_documented_exclusions(self) -> None:
|
||||
"""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"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
|
||||
def test_guard_sources_match_this_repository_provenance(self) -> None:
|
||||
"""Check copied guard files against local policy even before Git staging."""
|
||||
for filename in ("check-source-headers.py", "test-source-headers.py"):
|
||||
path = Path(__file__).with_name(filename)
|
||||
name = ".github/scripts/" + filename
|
||||
self.assertTrue(HEADERS.selected(name, POLICY))
|
||||
HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY)
|
||||
|
||||
def test_real_git_inventory_rejects_missing_tracked_header(self) -> None:
|
||||
"""A tracked new source fails while untracked generated output is ignored."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
|
||||
root.joinpath("src").mkdir()
|
||||
source = root / "src/a.py"
|
||||
source.write_text("print(1)", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
|
||||
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")
|
||||
root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8")
|
||||
self.assertEqual(1, HEADERS.check_repository(root, fixture_policy()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.",
|
||||
"includes": [
|
||||
".github/*.yml",
|
||||
".github/*.yaml",
|
||||
".github/scripts/*.py",
|
||||
"pom.xml",
|
||||
"checkstyle/*.xml",
|
||||
"dev/*.ps1",
|
||||
"dev/*.cs",
|
||||
"dev/*.csproj",
|
||||
"dev/*.java",
|
||||
"dev/*.sh",
|
||||
"dev/*.yaml",
|
||||
"dev/*.xml",
|
||||
"dev/Dockerfile.*",
|
||||
"dev/native-auth/Dockerfile.*",
|
||||
"src/main/java/*.java",
|
||||
"src/test/java/*.java",
|
||||
"src/test/resources/log4j2-test.xml"
|
||||
],
|
||||
"exclusions": [
|
||||
{
|
||||
"pattern": "*.json",
|
||||
"reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments."
|
||||
},
|
||||
{
|
||||
"pattern": "*.lock",
|
||||
"reason": "Dependency lock data, not owned executable source."
|
||||
},
|
||||
{
|
||||
"pattern": "*/generated/*",
|
||||
"reason": "Explicit generated-code directory."
|
||||
},
|
||||
{
|
||||
"pattern": "*/bin/*",
|
||||
"reason": "Generated build output."
|
||||
},
|
||||
{
|
||||
"pattern": "*/obj/*",
|
||||
"reason": "Generated build output."
|
||||
},
|
||||
{
|
||||
"pattern": "*/node_modules/*",
|
||||
"reason": "Third-party dependency output."
|
||||
},
|
||||
{
|
||||
"pattern": ".mvn/*",
|
||||
"reason": "Upstream Maven wrapper provenance."
|
||||
},
|
||||
{
|
||||
"pattern": "mvnw*",
|
||||
"reason": "Upstream Maven wrapper provenance."
|
||||
},
|
||||
{
|
||||
"pattern": "Assets/Legal/*",
|
||||
"reason": "Byte-exact third-party legal files."
|
||||
},
|
||||
{
|
||||
"pattern": "src/main/resources/*",
|
||||
"reason": "Bundled font/legal assets and OpenAPI schema data."
|
||||
},
|
||||
{
|
||||
"pattern": "src/test/resources/openapi/*",
|
||||
"reason": "Historical contract schema fixtures."
|
||||
},
|
||||
{
|
||||
"pattern": "*.slnx",
|
||||
"reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": "*.manifest",
|
||||
"reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": "*.runsettings",
|
||||
"reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": ".editorconfig",
|
||||
"reason": "Style configuration outside this pass; root LICENSE/NOTICE applies."
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
".py": "hash",
|
||||
".ps1": "powershell",
|
||||
".psd1": "powershell",
|
||||
".sh": "hash",
|
||||
".yml": "hash",
|
||||
".yaml": "hash",
|
||||
".cs": "slash",
|
||||
".java": "slash",
|
||||
".mjs": "slash",
|
||||
".js": "slash",
|
||||
".xml": "xml",
|
||||
".props": "xml",
|
||||
".csproj": "xml",
|
||||
".wxs": "xml",
|
||||
".wixproj": "xml",
|
||||
".xaml": "xml"
|
||||
},
|
||||
"notices": [
|
||||
{
|
||||
"paths": [
|
||||
"*"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"holder": "Kingsrook, LLC"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Build and prerelease
|
||||
|
||||
on:
|
||||
@@ -26,13 +29,34 @@ 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
|
||||
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Restore locked Pyright
|
||||
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.13.6'
|
||||
enable-cache: false
|
||||
- name: Restore locked Python tools
|
||||
run: uv sync --locked
|
||||
- name: Verify script quality gates and rejection paths
|
||||
run: |
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
- name: Verify owned source notices and rejection paths
|
||||
run: |
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
- name: Checkstyle, unit tests and HTTP contract tests
|
||||
run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify
|
||||
- name: Prepare JAR, POM and checksums
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
|
||||
# Pins, inputs and analysis checks preserved; report validation now fails closed.
|
||||
# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: codeql
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: development
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
native-auth:
|
||||
description: Run the opt-in, unpublished native-auth candidate qualification
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: development-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scripts:
|
||||
name: development-scripts (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Analyze and test development scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
|
||||
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
|
||||
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
$issues = @(Invoke-ScriptAnalyzer -Path dev -Recurse -Severity Warning,Error)
|
||||
if ($issues.Count) { $issues | Format-Table; throw 'Development script analysis failed.' }
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = './dev/tests'
|
||||
$configuration.Run.Exit = $true
|
||||
$configuration.TestRegistry.Enabled = $false
|
||||
Invoke-Pester -Configuration $configuration
|
||||
|
||||
docker:
|
||||
name: development-docker
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 50
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run disposable S3 and network printing smoke tests
|
||||
shell: pwsh
|
||||
run: ./dev/Invoke-Dev.ps1 -Action Test
|
||||
- name: Run opt-in real native provider qualification
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.native-auth
|
||||
shell: pwsh
|
||||
run: ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
|
||||
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
|
||||
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Local copy of KofTwentyTwo/standards security.yml at
|
||||
# fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
|
||||
# Scanner pins and security gates are preserved. SCA scans Maven-selected runtime
|
||||
@@ -62,7 +65,7 @@ jobs:
|
||||
# Resolve the actual deployed profile, including frontend assets.
|
||||
# Do not restore an Actions cache in this security job.
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased (0.4.0-SNAPSHOT)
|
||||
|
||||
Printing extension **2.3.0** (additive; wire payload v1 and the agent subprotocol are
|
||||
unchanged). 2.1.0 added the record, table-list and text print processes; 2.2.0 added
|
||||
`printingRenderToPdf`, the header and footer values on every document and content
|
||||
sections; 2.3.0 adds the least-privilege operator catalog/submission/request lookup,
|
||||
reported Windows queue-default media (`printer-media-v1`) and the opt-in
|
||||
retained-source facade. Clients should offer a feature when its process is in the
|
||||
metadata, not by comparing versions. An agent that does not advertise
|
||||
`printer-media-v1` stays a three-capability peer and cannot claim a job carrying a
|
||||
media selection, whatever extension version it discovers.
|
||||
|
||||
- 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
|
||||
`TABLE_GET`, and tables carrying `PrintingTableSettings().withPrintable(false)`
|
||||
are skipped.
|
||||
- The record sheet now follows the table's metadata: field sections in tier order
|
||||
with their labels, remaining fields under "Other fields", and a summary of each
|
||||
child list (association): its label, row count and first ten rows.
|
||||
- New `printingPrintTableList`: a table list (grid) of any readable table with the
|
||||
caller's columns (in order) and widths, filter and sort, either the whole result
|
||||
(`scope` `ALL`, the default: read a thousand rows at a time up to `maxRows`,
|
||||
10,000 by default and at most; a larger result prints its first rows and says
|
||||
so in the header and in `printingMessage`) or the current page (`PAGE`, the
|
||||
filter's skip/limit). Letter or A4; orientation `AUTO` (landscape when the
|
||||
columns need it), portrait or landscape. The header repeats on every page; the
|
||||
page header carries the table, the rows printed of the total, the filter text
|
||||
and the sort, and the footer the table, time, user and "Page n of m".
|
||||
`printingRowCount` reports the matching rows before printing.
|
||||
- New `printingPrintText`: any text (a capture of a terminal screen) as a
|
||||
monospace PDF, wrapped and broken across pages.
|
||||
- All documents share one page writer with "Page n of m" footers and a running
|
||||
title on later pages. Table and record reads enforce the user's permissions.
|
||||
|
||||
## 0.3.1-SNAPSHOT
|
||||
|
||||
- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a
|
||||
**Print** process (`printingPrintRecords_{table}`) to a host table's records and
|
||||
selections. It asks for an approved, exposed PDF printer and a copy count, shows
|
||||
the standard review, then renders each selected record as a Letter record sheet
|
||||
(`PrintingRecordDocuments`: heading, visible non-binary fields with display
|
||||
values, wrapped and continued across pages) and queues one PDF job per record.
|
||||
The queued job IDs are returned in the process value `printingJobIds`.
|
||||
- Fix selection actions whose first step is a form (**Print a test page**,
|
||||
**Print a test label** on selected printers, and **Print**): over the UI API the
|
||||
record selection reaches only the init call, so the review answered "No records
|
||||
were selected". The prepare step now keeps the selection as `queryFilterJson`.
|
||||
|
||||
## 0.3.0-rc.1 — 2026-10-31
|
||||
|
||||
Upgrade the server before the matching Windows connector. Native printing API
|
||||
|
||||
@@ -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.
|
||||
@@ -19,6 +19,12 @@ flowchart LR
|
||||
|
||||
See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE).
|
||||
|
||||
## Local development
|
||||
|
||||
Start an isolated native QQQ application, MinIO and optional Authentik with the
|
||||
[Docker development commands](dev/README.md). The default test runs synthetic
|
||||
Java/.NET WebSocket jobs and S3 checks without an installed connector or printer.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11).
|
||||
@@ -135,6 +141,8 @@ JSONObject job = printing.submit(new JSONObject()
|
||||
String printJobId = job.getString("jobId");
|
||||
```
|
||||
|
||||
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`).
|
||||
|
||||
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.
|
||||
|
||||
For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Local Authentik reference
|
||||
|
||||
Run the optional real-provider checks with Docker Compose and PowerShell 7.4+.
|
||||
The pinned fixture images use Linux/amd64; other hosts need Docker's corresponding
|
||||
Linux-container support or emulation:
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
|
||||
```
|
||||
|
||||
The command creates a fresh, owned development project, runs the normal QQQ and
|
||||
MinIO smoke test plus the Authentik reference checks, then removes that project's
|
||||
containers and volumes. It does not operate the installed Windows connector,
|
||||
existing Docker stacks or physical printers. It needs no cloud account.
|
||||
|
||||
For an environment that remains available for development, use `-Action Start`
|
||||
with an explicit `-StateDirectory` outside the checkout. Use that same directory
|
||||
with `-Action Test -Authentik`, then `-Action Stop` when finished. Stop destroys
|
||||
only that owned project's synthetic databases and objects; retained private
|
||||
reports are separate. See the [development command](Invoke-Dev.ps1) for port
|
||||
overrides. Authentik defaults to `http://127.0.0.1:19010`;
|
||||
its database port is not published. The QQQ host remains on standard TABLE
|
||||
authentication.
|
||||
|
||||
## What the automated check establishes
|
||||
|
||||
- Real Authentik 2026.8.5 starts with a separate PostgreSQL 16 Alpine database.
|
||||
- A synthetic confidential OAuth provider/application and development user are
|
||||
configured through Authentik's API. The callback list is explicit and grants
|
||||
are limited to authorization code and refresh. Access/refresh validity is
|
||||
two/ten minutes for a future bounded renewal test.
|
||||
- Actual provider discovery has the expected issuer, same-origin endpoints and
|
||||
S256 capability. A valid state/S256 request with the registered callback
|
||||
continues to the provider login flow without following it. Changing only that
|
||||
callback to an unregistered redirect is rejected before any user login.
|
||||
- The current QQQ RC.20 host advertises UI API 1.8 without `nativeSessionLogin`.
|
||||
That expected absence is recorded as a native-client prerequisite failure,
|
||||
not silently replaced by bearer authentication or an unpublished core.
|
||||
|
||||
`auth-result.json` distinguishes those checks from browser login, renewal and
|
||||
the Windows callback, which are **not run**. Provider setup/discovery is not an
|
||||
OIDC user-session integration test. No password login dialog is automated.
|
||||
|
||||
## Credentials and ownership
|
||||
|
||||
The common entrypoint creates a protected state directory outside the checkout.
|
||||
`Invoke-Authentik.ps1 -Action Initialize` appends only its `AUTHENTIK_*` settings
|
||||
to `compose.env`, preserving the QQQ/MinIO settings. It records synthetic
|
||||
credentials in private `auth-secrets.json`; neither file belongs in Git, a bug
|
||||
report or terminal output. Bootstrap uses a Django password hash. The temporary
|
||||
bootstrap API token remains confined to this disposable project and disappears
|
||||
with its database at cleanup. Do not reuse these accounts outside the fixture.
|
||||
|
||||
The Docker administrator can inspect container configuration. The scripts do not
|
||||
print credentials or provider error bodies, follow redirects with an API token,
|
||||
or request raw container logs. HTTP reads have a 15-second cancellation deadline
|
||||
and a 512 KiB response limit. Startup has a bounded readiness loop. Initializer
|
||||
replays retain credentials; conflicting or partial state is refused.
|
||||
|
||||
Compose pins cached/public image digests, not floating tags:
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
| Component | Image digest |
|
||||
| --- | --- |
|
||||
| Authentik server/worker 2026.8.5 | `ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5` |
|
||||
| PostgreSQL 16 Alpine | `postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea` |
|
||||
|
||||
<!-- markdownlint-enable MD013 -->
|
||||
|
||||
There is no Redis requirement for this Authentik version, Docker socket mount,
|
||||
outpost management, shared volume or automatic restart policy. Container and
|
||||
volume labels identify the exact generated Compose project. The common cleanup
|
||||
checks those labels before removing resources; it never performs a global prune.
|
||||
|
||||
## Separate native-session qualification
|
||||
|
||||
The Windows native path requires the centrally defined API 1.9 typed
|
||||
`AUTHORIZATION_CODE_PKCE_SESSION` grant and trusted HTTPS on both QQQ and provider
|
||||
origins. RC.20 does not supply that grant, and this HTTP development profile does
|
||||
not weaken the client's HTTPS rules. The synthetic reference callback is only a
|
||||
provider configuration value; no callback receiver or QQQ OAuth adapter is
|
||||
claimed here. The TABLE host and installed pairing are unchanged.
|
||||
|
||||
The separate [experimental native profile](native-auth/README.md) builds an exact
|
||||
public, unreleased QQQ candidate inside Docker and uses private container-only
|
||||
HTTPS trust. On 2026-11-01 it passed actual provider login, native permission
|
||||
denials, logout replay rejection, renewal after expiry and provider revocation
|
||||
with the production Core client. Its synthetic HTTP driver and HTTPS callback
|
||||
receiver do not qualify the installed Windows browser UI or private URI callback.
|
||||
|
||||
Production adoption still requires a compatible published central artifact
|
||||
tuple. Provider refresh tokens stay on the server. The short lifetime test does
|
||||
not prove months or years of unattended use. Do not route the private Windows
|
||||
callback to a live companion whose connection must remain intact.
|
||||
|
||||
The setup follows the official [2026.8 Compose definition](https://goauthentik.io/version/2026.8/lifecycle/container/compose.yml),
|
||||
[bootstrap contract](https://docs.goauthentik.io/install-config/automated-install/)
|
||||
and [OAuth provider contract](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/).
|
||||
The repository supplies the repeatable local setup; provider/browser acceptance
|
||||
is reported separately from TABLE authentication and printing workload tests.
|
||||
|
||||
## Initializer regression tests
|
||||
|
||||
With Pester 5 available, run `dev/tests/Authentik.Tests.ps1`. They use temporary
|
||||
files only: no Docker, HTTP, installed service or registry access is required.
|
||||
Disable Pester's unrelated registry fixture on Windows:
|
||||
|
||||
```powershell
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = './dev/tests/Authentik.Tests.ps1'
|
||||
$configuration.Run.Exit = $true
|
||||
$configuration.TestRegistry.Enabled = $false
|
||||
Invoke-Pester -Configuration $configuration
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build
|
||||
# Exact reviewed published Core source; no workstation DLL or mutable branch.
|
||||
ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
WORKDIR /fixture
|
||||
COPY dev/client .
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet /out/Workload.dll --check-discovery
|
||||
# SDK image is reused deliberately: one pinned image, no hidden host SDK dependency.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
WORKDIR /app
|
||||
COPY --from=build /out .
|
||||
ENV DOTNET_EnableDiagnostics=0
|
||||
ENTRYPOINT ["dotnet","/app/Workload.dll"]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build
|
||||
WORKDIR /source
|
||||
COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Pui-demo -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/java /fixture-src
|
||||
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
ENV PRINTING_SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
WORKDIR /app
|
||||
COPY --from=build /source/target/classes classes
|
||||
COPY --from=build /source/target/runtime lib
|
||||
COPY --from=build /fixture-classes fixture
|
||||
COPY dev/log4j2.xml /app/log4j2.xml
|
||||
COPY dev/run-tests.sh /app/run-tests.sh
|
||||
RUN chmod 0555 /app/run-tests.sh
|
||||
ENTRYPOINT ["java","-Xmx768m","-Dlog4j.configurationFile=/app/log4j2.xml","-cp","/app/classes:/app/lib/*:/app/fixture"]
|
||||
CMD ["com.kingsrook.qbits.printing.DevHost"]
|
||||
@@ -0,0 +1,397 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prepares or checks the optional, owned Authentik development fixture.
|
||||
.DESCRIPTION
|
||||
Initialize only writes private state. Test configures a fresh synthetic provider
|
||||
and checks its real HTTP discovery and rejection boundary; it never automates a
|
||||
user login or changes QQQ authentication. The common development entrypoint owns
|
||||
all Docker lifecycle operations. Provider response bodies and secrets are never
|
||||
printed, including on errors.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('Initialize', 'Test')][string] $Action,
|
||||
[Parameter(Mandatory)][string] $StateDirectory,
|
||||
[Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName,
|
||||
[ValidateRange(1024, 65535)][int] $Port = 19010,
|
||||
[ValidateRange(10, 300)][int] $ReadyTimeoutSeconds = 180
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
<# .SYNOPSIS Generates a private random value without placing it on a command line. #>
|
||||
function Get-FixtureSecret
|
||||
{
|
||||
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
}
|
||||
|
||||
<# .SYNOPSIS Limits the helper to an existing state directory outside the checkout. #>
|
||||
function Get-FixtureState
|
||||
{
|
||||
param([string] $Directory)
|
||||
$state = [IO.Path]::GetFullPath($Directory)
|
||||
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
|
||||
if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison))
|
||||
{
|
||||
throw 'Development secrets must be outside the source checkout.'
|
||||
}
|
||||
if(-not (Test-Path -LiteralPath $state -PathType Container) -or -not (Test-Path -LiteralPath (Join-Path $state 'compose.env') -PathType Leaf))
|
||||
{
|
||||
throw 'The common development entrypoint must create protected state and compose.env first.'
|
||||
}
|
||||
$ancestor = [IO.DirectoryInfo]::new($state)
|
||||
while($null -ne $ancestor)
|
||||
{
|
||||
if(($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
{
|
||||
throw 'Development state cannot use a symbolic link or reparse point.'
|
||||
}
|
||||
$ancestor = $ancestor.Parent
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
<# .SYNOPSIS Reads private configuration without echoing malformed JSON or credential values. #>
|
||||
function Get-FixtureConfiguration
|
||||
{
|
||||
param([string] $Path)
|
||||
try
|
||||
{
|
||||
$value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
|
||||
foreach($field in @('project', 'port', 'adminPassword', 'bootstrapToken', 'clientId', 'clientSecret', 'username', 'userPassword'))
|
||||
{
|
||||
if(-not $value.ContainsKey($field) -or $null -eq $value[$field]) { throw 'Missing field.' }
|
||||
}
|
||||
return $value
|
||||
}
|
||||
catch { throw 'The private Authentik configuration is unreadable or incomplete; its contents are withheld.' }
|
||||
}
|
||||
|
||||
<# .SYNOPSIS Performs bounded same-origin HTTP without redirects or response-body diagnostics. #>
|
||||
function Invoke-FixtureHttp
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][Net.Http.HttpClient] $Client,
|
||||
[Parameter(Mandatory)][string] $Path,
|
||||
[ValidateSet('GET', 'POST')][string] $Method = 'GET',
|
||||
[object] $Body,
|
||||
[string] $Token,
|
||||
[int[]] $ExpectedStatus = @(200)
|
||||
)
|
||||
if(-not $Path.StartsWith('/', [StringComparison]::Ordinal) -or $Path.StartsWith('//', [StringComparison]::Ordinal))
|
||||
{
|
||||
throw 'Only an absolute path on the fixture origin is permitted.'
|
||||
}
|
||||
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path)
|
||||
$timeout = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15))
|
||||
$response = $null
|
||||
$status = 0
|
||||
try
|
||||
{
|
||||
if($Token)
|
||||
{
|
||||
$request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token)
|
||||
}
|
||||
if($null -ne $Body)
|
||||
{
|
||||
$request.Content = [Net.Http.StringContent]::new(($Body | ConvertTo-Json -Depth 12 -Compress), [Text.Encoding]::UTF8, 'application/json')
|
||||
}
|
||||
$response = $Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult()
|
||||
$status = [int]$response.StatusCode
|
||||
if($status -notin $ExpectedStatus)
|
||||
{
|
||||
throw "Fixture HTTP status $status was not expected. Response content is withheld."
|
||||
}
|
||||
$stream = $response.Content.ReadAsStreamAsync($timeout.Token).GetAwaiter().GetResult()
|
||||
$bytes = [IO.MemoryStream]::new()
|
||||
try
|
||||
{
|
||||
$buffer = [byte[]]::new(8192)
|
||||
while(($read = $stream.ReadAsync($buffer, 0, $buffer.Length, $timeout.Token).GetAwaiter().GetResult()) -gt 0)
|
||||
{
|
||||
if($bytes.Length + $read -gt 524288)
|
||||
{
|
||||
throw 'Fixture response exceeded the 512 KiB limit.'
|
||||
}
|
||||
$bytes.Write($buffer, 0, $read)
|
||||
}
|
||||
$text = [Text.Encoding]::UTF8.GetString($bytes.ToArray())
|
||||
$data = if($text.Length -gt 0 -and $response.Content.Headers.ContentType.MediaType -eq 'application/json')
|
||||
{
|
||||
ConvertFrom-Json -InputObject $text -AsHashtable
|
||||
}
|
||||
else { $null }
|
||||
return @{ Status = $status; Data = $data; Location = $response.Headers.Location }
|
||||
}
|
||||
finally
|
||||
{
|
||||
$bytes.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
# The failing API may contain passwords/tokens in its error response.
|
||||
$operation = [uri]::new($Client.BaseAddress, $Path).AbsolutePath
|
||||
throw "Fixture $Method $operation failed (HTTP $status; 0 means no response). Response content and authentication values are withheld."
|
||||
}
|
||||
finally
|
||||
{
|
||||
if($null -ne $response) { $response.Dispose() }
|
||||
$request.Dispose()
|
||||
$timeout.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
<# .SYNOPSIS Requires one exact default object rather than choosing an arbitrary provider resource. #>
|
||||
function Get-FixtureObject
|
||||
{
|
||||
param([Net.Http.HttpClient] $Client, [string] $Path, [string] $Token)
|
||||
$page = (Invoke-FixtureHttp -Client $Client -Path $Path -Token $Token).Data
|
||||
if($null -eq $page -or $page.results.Count -ne 1)
|
||||
{
|
||||
throw 'A required Authentik default object is missing or ambiguous.'
|
||||
}
|
||||
return $page.results[0]
|
||||
}
|
||||
|
||||
$state = Get-FixtureState -Directory $StateDirectory
|
||||
$envPath = Join-Path $state 'compose.env'
|
||||
$secretPath = Join-Path $state 'auth-secrets.json'
|
||||
foreach($path in @($envPath, $secretPath))
|
||||
{
|
||||
if((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
{
|
||||
throw 'Development configuration cannot use a symbolic link or reparse point.'
|
||||
}
|
||||
}
|
||||
if($Action -eq 'Initialize')
|
||||
{
|
||||
if(Test-Path -LiteralPath $secretPath)
|
||||
{
|
||||
$existing = Get-FixtureConfiguration -Path $secretPath
|
||||
if($existing.project -ne $ProjectName -or $existing.port -ne $Port)
|
||||
{
|
||||
throw 'Existing Authentik state belongs to a different project or port.'
|
||||
}
|
||||
$existingEnvironment = Get-Content -LiteralPath $envPath -Raw
|
||||
foreach($key in @('AUTHENTIK_HTTP_PORT', 'AUTHENTIK_POSTGRES_PASSWORD', 'AUTHENTIK_SECRET_KEY', 'AUTHENTIK_BOOTSTRAP_PASSWORD_HASH', 'AUTHENTIK_BOOTSTRAP_TOKEN'))
|
||||
{
|
||||
if([regex]::Matches($existingEnvironment, '(?m)^' + $key + '=').Count -ne 1)
|
||||
{
|
||||
throw 'The private Authentik environment is incomplete or ambiguous; use a fresh owned state directory.'
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
$contents = Get-Content -LiteralPath $envPath -Raw
|
||||
if($contents -match '(?m)^AUTHENTIK_')
|
||||
{
|
||||
throw 'Partial Authentik initialization exists. Use a fresh owned state directory.'
|
||||
}
|
||||
$adminPassword = Get-FixtureSecret
|
||||
$salt = Get-FixtureSecret
|
||||
$passwordHash = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($adminPassword, [Text.Encoding]::UTF8.GetBytes($salt), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32))
|
||||
$secrets = [ordered]@{
|
||||
project = $ProjectName
|
||||
port = $Port
|
||||
adminPassword = $adminPassword
|
||||
bootstrapToken = Get-FixtureSecret
|
||||
clientId = $ProjectName
|
||||
clientSecret = Get-FixtureSecret
|
||||
username = 'printing-developer'
|
||||
userPassword = Get-FixtureSecret
|
||||
}
|
||||
$secrets | ConvertTo-Json | Set-Content -LiteralPath $secretPath -Encoding utf8
|
||||
$values = [ordered]@{
|
||||
AUTHENTIK_HTTP_PORT = $Port
|
||||
AUTHENTIK_POSTGRES_PASSWORD = Get-FixtureSecret
|
||||
AUTHENTIK_SECRET_KEY = Get-FixtureSecret
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH = 'pbkdf2_sha256$1000000$' + $salt + '$' + $passwordHash
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN = $secrets.bootstrapToken
|
||||
}
|
||||
# Single quotes preserve the dollar signs in Django's password hash.
|
||||
$lines = @('') + @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" })
|
||||
Add-Content -LiteralPath $envPath -Value $lines -Encoding utf8
|
||||
if(-not $IsWindows)
|
||||
{
|
||||
[IO.File]::SetUnixFileMode($secretPath, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$secrets = Get-FixtureConfiguration -Path $secretPath
|
||||
if($secrets.project -cne $ProjectName -or $secrets.port -ne $Port -or $secrets.clientId -cne $ProjectName -or $secrets.username -cne 'printing-developer')
|
||||
{
|
||||
throw 'Authentik state does not match the owned development project.'
|
||||
}
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$handler.AllowAutoRedirect = $false
|
||||
$handler.UseProxy = $false
|
||||
$handler.UseCookies = $false
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$client.BaseAddress = [uri]"http://127.0.0.1:$Port"
|
||||
$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan
|
||||
try
|
||||
{
|
||||
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($ReadyTimeoutSeconds)
|
||||
$ready = $false
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
$authorization = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent' -Token $secrets.bootstrapToken
|
||||
$invalidation = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-invalidation-flow' -Token $secrets.bootstrapToken
|
||||
$ready = $true
|
||||
}
|
||||
catch
|
||||
{
|
||||
if([DateTimeOffset]::UtcNow -ge $deadline) { throw 'Authentik did not become ready before the bounded startup deadline.' }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
} while(-not $ready)
|
||||
|
||||
$mappingIds = @(foreach($scope in @('openid', 'profile', 'email', 'offline_access'))
|
||||
{
|
||||
(Get-FixtureObject -Client $client -Path "/api/v3/propertymappings/provider/scope/?scope_name=$scope" -Token $secrets.bootstrapToken).pk
|
||||
})
|
||||
$providerPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/providers/oauth2/?client_id=' + $secrets.clientId) -Token $secrets.bootstrapToken).Data
|
||||
$callback = "http://127.0.0.1:$Port/reference-callback/"
|
||||
if($providerPage.results.Count -eq 0)
|
||||
{
|
||||
$provider = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/providers/oauth2/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{
|
||||
name = $ProjectName
|
||||
authorization_flow = $authorization.pk
|
||||
invalidation_flow = $invalidation.pk
|
||||
client_type = 'confidential'
|
||||
grant_types = @('authorization_code', 'refresh_token')
|
||||
client_id = $secrets.clientId
|
||||
client_secret = $secrets.clientSecret
|
||||
access_code_validity = 'minutes=1'
|
||||
access_token_validity = 'minutes=2'
|
||||
refresh_token_validity = 'minutes=10'
|
||||
redirect_uris = @(@{ matching_mode = 'strict'; url = $callback })
|
||||
property_mappings = $mappingIds
|
||||
issuer_mode = 'per_provider'
|
||||
}).Data
|
||||
}
|
||||
elseif($providerPage.results.Count -eq 1)
|
||||
{
|
||||
$provider = $providerPage.results[0]
|
||||
if($provider.name -ne $ProjectName) { throw 'The fixture provider identity changed.' }
|
||||
}
|
||||
else { throw 'The fixture provider identity is ambiguous.' }
|
||||
if($provider.redirect_uris.Count -ne 1 -or $provider.redirect_uris[0].matching_mode -cne 'strict' -or
|
||||
$provider.redirect_uris[0].url -cne $callback -or $provider.client_type -cne 'confidential' -or
|
||||
$provider.access_token_validity -cne 'minutes=2' -or $provider.refresh_token_validity -cne 'minutes=10' -or
|
||||
(@($provider.grant_types | Sort-Object) -join ',') -cne 'authorization_code,refresh_token' -or
|
||||
@($mappingIds | Where-Object { $_ -notin $provider.property_mappings }).Count -ne 0)
|
||||
{
|
||||
throw 'The existing provider configuration differs from the bounded reference settings.'
|
||||
}
|
||||
|
||||
$applicationPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/applications/?slug=' + $ProjectName) -Token $secrets.bootstrapToken).Data
|
||||
if($applicationPage.results.Count -eq 0)
|
||||
{
|
||||
$null = Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/applications/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ name = $ProjectName; slug = $ProjectName; provider = $provider.pk; policy_engine_mode = 'all' }
|
||||
}
|
||||
elseif($applicationPage.results.Count -ne 1 -or $applicationPage.results[0].provider -ne $provider.pk)
|
||||
{
|
||||
throw 'The fixture application does not match its provider.'
|
||||
}
|
||||
$userPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/users/?username=' + $secrets.username) -Token $secrets.bootstrapToken).Data
|
||||
if($userPage.results.Count -eq 0)
|
||||
{
|
||||
$user = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/users/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ username = $secrets.username; name = 'Printing development user'; email = 'printing-developer@example.invalid'; is_active = $true; type = 'internal' }).Data
|
||||
}
|
||||
elseif($userPage.results.Count -eq 1)
|
||||
{
|
||||
$user = $userPage.results[0]
|
||||
if($user.name -cne 'Printing development user') { throw 'The synthetic user identity changed.' }
|
||||
}
|
||||
else { throw 'The synthetic user identity is ambiguous.' }
|
||||
# Reusing the same generated value also recovers an interrupted first setup.
|
||||
$null = Invoke-FixtureHttp -Client $client -Method POST -Path ("/api/v3/core/users/$($user.pk)/set_password/") -Token $secrets.bootstrapToken -ExpectedStatus @(204) -Body @{ password = $secrets.userPassword }
|
||||
|
||||
$issuer = "http://127.0.0.1:$Port/application/o/$ProjectName/"
|
||||
$discovery = (Invoke-FixtureHttp -Client $client -Path "/application/o/$ProjectName/.well-known/openid-configuration").Data
|
||||
if($discovery.issuer -cne $issuer -or 'S256' -notin $discovery.code_challenge_methods_supported)
|
||||
{
|
||||
throw 'The real provider discovery does not match the fixture issuer and S256 capability.'
|
||||
}
|
||||
foreach($endpoint in @('authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'))
|
||||
{
|
||||
$uri = [uri]$discovery[$endpoint]
|
||||
if($uri.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port")
|
||||
{
|
||||
throw 'Provider discovery returned an endpoint outside the fixture origin.'
|
||||
}
|
||||
}
|
||||
$verifier = Get-FixtureSecret
|
||||
$challenge = [Convert]::ToBase64String([Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
$stateValue = Get-FixtureSecret
|
||||
$authorize = "/application/o/authorize/?client_id=$($secrets.clientId)&response_type=code&scope=openid%20profile%20email%20offline_access&state=$stateValue&code_challenge=$challenge&code_challenge_method=S256&prompt=login&redirect_uri="
|
||||
$accepted = Invoke-FixtureHttp -Client $client -Path ($authorize + [uri]::EscapeDataString($callback)) -ExpectedStatus @(302, 303)
|
||||
if($null -eq $accepted.Location)
|
||||
{
|
||||
throw 'The registered redirect did not continue to the real provider login flow.'
|
||||
}
|
||||
$login = [uri]::new($client.BaseAddress, $accepted.Location)
|
||||
if($login.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port" -or -not $login.AbsolutePath.StartsWith('/if/flow/', [StringComparison]::Ordinal))
|
||||
{
|
||||
throw 'The registered redirect did not continue to the same-origin provider login flow.'
|
||||
}
|
||||
# Change only the callback: a generic malformed authorization request is not proof.
|
||||
$invalidRedirect = [uri]::EscapeDataString("http://127.0.0.1:$Port/not-registered/")
|
||||
$null = Invoke-FixtureHttp -Client $client -Path ($authorize + $invalidRedirect) -ExpectedStatus @(400)
|
||||
$qqqPortMatch = [regex]::Match((Get-Content -LiteralPath $envPath -Raw), '(?m)^QQQ_HTTP_PORT=[''"]?(\d+)[''"]?\s*$')
|
||||
if(-not $qqqPortMatch.Success) { throw 'The common environment has no QQQ HTTP port.' }
|
||||
$qqqHandler = [Net.Http.HttpClientHandler]::new()
|
||||
$qqqHandler.AllowAutoRedirect = $false
|
||||
$qqqHandler.UseProxy = $false
|
||||
$qqqClient = [Net.Http.HttpClient]::new($qqqHandler)
|
||||
try
|
||||
{
|
||||
$qqqClient.BaseAddress = [uri]("http://127.0.0.1:" + $qqqPortMatch.Groups[1].Value)
|
||||
$qqqClient.Timeout = [Threading.Timeout]::InfiniteTimeSpan
|
||||
$qqqClient.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-dev-reference')
|
||||
$version = (Invoke-FixtureHttp -Client $qqqClient -Path '/qqq/v1/version').Data
|
||||
}
|
||||
finally { $qqqClient.Dispose() }
|
||||
if($version.apiVersion -ne '1.8.0' -or 'nativeSessionLogin' -in $version.features)
|
||||
{
|
||||
throw 'The core capability baseline changed; review the Authentik qualification scope before proceeding.'
|
||||
}
|
||||
[ordered]@{
|
||||
status = 'PASS'
|
||||
observedUtc = [DateTimeOffset]::UtcNow.ToString('O')
|
||||
project = $ProjectName
|
||||
providerOrigin = "http://127.0.0.1:$Port"
|
||||
providerVersion = '2026.8.5'
|
||||
discovery = 'PASS'
|
||||
registeredRedirect = 'LOGIN_REQUIRED'
|
||||
unregisteredRedirect = 'REJECTED'
|
||||
qqqApiVersion = $version.apiVersion
|
||||
nativeSessionLogin = 'UNAVAILABLE_EXPECTED'
|
||||
browserLogin = 'NOT_RUN_OPERATOR_REQUIRED'
|
||||
renewal = 'NOT_RUN'
|
||||
windowsCallback = 'NOT_RUN_REQUIRES_QUALIFIED_CORE_AND_HTTPS'
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
|
||||
Write-Output 'Authentik reference discovery and redirect rejection passed; QQQ native browser login remains unavailable on RC.20.'
|
||||
}
|
||||
catch
|
||||
{
|
||||
@{ status = 'FAILED'; observedUtc = [DateTimeOffset]::UtcNow.ToString('O'); project = $ProjectName; message = 'Reference checks failed; no browser or native-login success is claimed.' } |
|
||||
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
|
||||
throw
|
||||
}
|
||||
finally
|
||||
{
|
||||
$client.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Starts, tests, or removes one owned local Docker printing environment.
|
||||
.DESCRIPTION
|
||||
Uses synthetic accounts and documents. Never imports installed device credentials,
|
||||
contacts printers, changes host trust, or prunes shared Docker resources.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Start', 'Test', 'Stop')][string]$Action = 'Test',
|
||||
[string]$StateDirectory,
|
||||
[switch]$Authentik,
|
||||
[switch]$ExperimentalNativeAuth,
|
||||
[ValidateSet(40, 10000)][int]$Workload = 40,
|
||||
[ValidateRange(1024, 65535)][int]$QqqPort = 19081,
|
||||
[ValidateRange(1024, 65535)][int]$MinioPort = 19095,
|
||||
[ValidateRange(1024, 65535)][int]$AuthentikPort = 19010
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$ephemeral = $Action -eq 'Test' -and [string]::IsNullOrWhiteSpace($StateDirectory)
|
||||
if ($ExperimentalNativeAuth)
|
||||
{
|
||||
if ($Action -ne 'Test')
|
||||
{
|
||||
throw 'Experimental native authentication is a disposable Test-only profile.'
|
||||
}
|
||||
$Authentik = $true
|
||||
}
|
||||
|
||||
function Assert-StateLocation([string]$Path)
|
||||
{
|
||||
<# .SYNOPSIS Rejects links before any private state is read or written. #>
|
||||
$ancestor = [IO.DirectoryInfo]::new($Path)
|
||||
while ($null -ne $ancestor)
|
||||
{
|
||||
if (![string]::IsNullOrEmpty($ancestor.LinkTarget) -or ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))
|
||||
{
|
||||
throw 'Development state cannot use a symbolic link or reparse point.'
|
||||
}
|
||||
$ancestor = $ancestor.Parent
|
||||
}
|
||||
foreach ($name in @('owner.json', 'compose.env', 'compose.yaml', 'compose.authentik.yaml', 'compose.native-auth.yaml', 'auth-secrets.json', 'qqq-result.json', 'auth-result.json', 'native-input.json', 'native-auth-result.json', 'native-auth-server-errors.json'))
|
||||
{
|
||||
$file = [IO.FileInfo]::new((Join-Path $Path $name))
|
||||
if (![string]::IsNullOrEmpty($file.LinkTarget) -or ($file.Exists -and ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))
|
||||
{
|
||||
throw 'Development state files cannot use symbolic links or reparse points.'
|
||||
}
|
||||
}
|
||||
}
|
||||
function Get-RecordedAuthentikPort
|
||||
{
|
||||
<# .SYNOPSIS Restores the existing environment port without reading it from caller defaults. #>
|
||||
$lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { $_.StartsWith('AUTHENTIK_HTTP_PORT=', [StringComparison]::Ordinal) })
|
||||
if ($lines.Count -ne 1 -or $lines[0] -cnotmatch '^AUTHENTIK_HTTP_PORT=([0-9]{4,5})$')
|
||||
{
|
||||
throw 'Recorded Authentik port is missing or invalid.'
|
||||
}
|
||||
$port = [int]$Matches[1]
|
||||
if ($port -lt 1024 -or $port -gt 65535)
|
||||
{
|
||||
throw 'Recorded Authentik port is outside the supported range.'
|
||||
}
|
||||
return $port
|
||||
}
|
||||
function Get-FixtureSecret
|
||||
{
|
||||
<# .SYNOPSIS Generates only disposable local credentials. #>
|
||||
return [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
}
|
||||
function Protect-State([string]$Path)
|
||||
{
|
||||
<# .SYNOPSIS Restricts the generated state to its user and local administrators. #>
|
||||
if ($IsWindows)
|
||||
{
|
||||
$acl = [Security.AccessControl.DirectorySecurity]::new()
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||
[Security.Principal.SecurityIdentifier]::new('S-1-5-18'), [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')))
|
||||
{
|
||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
||||
}
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
}
|
||||
else
|
||||
{
|
||||
[IO.File]::SetUnixFileMode($Path, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute)
|
||||
}
|
||||
}
|
||||
function Set-FixtureEnvironment
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param([string]$Name, [string]$Value)
|
||||
<# .SYNOPSIS Updates one safe dotenv field without displaying secret values. #>
|
||||
if ($Name -cnotmatch '^[A-Z_]+$' -or $Value -match '[\r\n]')
|
||||
{
|
||||
throw 'Unsafe generated environment field.'
|
||||
}
|
||||
if (!$PSCmdlet.ShouldProcess($Name, 'Write protected fixture setting'))
|
||||
{
|
||||
return
|
||||
}
|
||||
$lines = @()
|
||||
if (Test-Path -LiteralPath $script:envFile)
|
||||
{
|
||||
$lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { !$_.StartsWith($Name + '=', [StringComparison]::Ordinal) })
|
||||
}
|
||||
[IO.File]::WriteAllLines($script:envFile, @($lines) + @($Name + '=' + $Value))
|
||||
}
|
||||
function Invoke-Compose([string[]]$Arguments)
|
||||
{
|
||||
<# .SYNOPSIS Runs only this recorded Compose project. #>
|
||||
& docker compose @script:composeArguments @Arguments
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Owned Docker Compose operation failed; inspect local command output.'
|
||||
}
|
||||
}
|
||||
function Assert-OwnedResource
|
||||
{
|
||||
<# .SYNOPSIS Refuses cleanup if any project resource lacks the ownership label. #>
|
||||
$ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project")
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Cannot inspect owned Docker containers.'
|
||||
}
|
||||
foreach ($id in $ids)
|
||||
{
|
||||
$label = & docker inspect $id --format '{{index .Config.Labels "io.qrun.printing.dev"}}'
|
||||
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
|
||||
{
|
||||
throw 'Container ownership mismatch; cleanup refused.'
|
||||
}
|
||||
}
|
||||
$networks = @(& docker network ls -q --filter "label=com.docker.compose.project=$script:project")
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Cannot inspect owned Docker networks.'
|
||||
}
|
||||
foreach ($network in $networks)
|
||||
{
|
||||
$label = & docker network inspect $network --format '{{index .Labels "io.qrun.printing.dev"}}'
|
||||
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
|
||||
{
|
||||
throw 'Network ownership mismatch; cleanup refused.'
|
||||
}
|
||||
}
|
||||
$volumes = @(& docker volume ls -q --filter "label=com.docker.compose.project=$script:project")
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Cannot inspect owned Docker volumes.'
|
||||
}
|
||||
foreach ($volume in $volumes)
|
||||
{
|
||||
$label = & docker volume inspect $volume --format '{{index .Labels "io.qrun.printing.dev"}}'
|
||||
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
|
||||
{
|
||||
throw 'Volume ownership mismatch; cleanup refused.'
|
||||
}
|
||||
}
|
||||
}
|
||||
function Stop-OwnedEnvironment
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param()
|
||||
<# .SYNOPSIS Removes only this environment's containers, network and synthetic volumes. #>
|
||||
if (!$PSCmdlet.ShouldProcess($script:project, 'Remove this owned development environment and its synthetic data'))
|
||||
{
|
||||
return
|
||||
}
|
||||
Assert-OwnedResource
|
||||
Invoke-Compose @('--profile', 'test', '--profile', 'auth', '--profile', 'native-auth', 'down', '--remove-orphans', '--volumes', '--timeout', '30')
|
||||
}
|
||||
function ConvertTo-ProviderErrorSummary([string[]]$Lines)
|
||||
{
|
||||
<# .SYNOPSIS Retains only exception types and bounded source-frame coordinates. #>
|
||||
$types = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||
$frames = [Collections.Generic.List[object]]::new()
|
||||
$warnings = @{invalidClientIdentifier = $false; nonApplicableAuthenticationFlow = $false; failedToResolveApplication = $false}
|
||||
foreach ($line in ($Lines | Select-Object -Last 1000))
|
||||
{
|
||||
if ($line.Length -gt 65536) { continue }
|
||||
$warnings.invalidClientIdentifier = $warnings.invalidClientIdentifier -or $line.Contains('Invalid client identifier', [StringComparison]::Ordinal)
|
||||
$warnings.nonApplicableAuthenticationFlow = $warnings.nonApplicableAuthenticationFlow -or $line.Contains('Non-applicable authentication flow', [StringComparison]::Ordinal)
|
||||
$warnings.failedToResolveApplication = $warnings.failedToResolveApplication -or $line.Contains('failed to resolve application', [StringComparison]::Ordinal)
|
||||
try { $record = ConvertFrom-Json -InputObject $line -AsHashtable -ErrorAction Stop }
|
||||
catch { continue }
|
||||
if ($record -isnot [Collections.IDictionary]) { continue }
|
||||
foreach ($exception in @($record['exception']))
|
||||
{
|
||||
if ($exception -isnot [Collections.IDictionary]) { continue }
|
||||
$type = [string]$exception['exc_type']
|
||||
if ($types.Count -lt 16 -and ($type -cmatch '^[A-Za-z_][A-Za-z0-9_.]{0,119}(Error|Exception)$' -or $type -cin @('PermissionDenied', 'NotAuthenticated', 'AuthenticationFailed', 'Throttled'))) { [void]$types.Add($type) }
|
||||
foreach ($frame in @($exception['frames']))
|
||||
{
|
||||
if ($frames.Count -ge 12 -or $frame -isnot [Collections.IDictionary]) { continue }
|
||||
$filename = [string]$frame['filename']
|
||||
$function = [string]$frame['name']
|
||||
$lineNumber = 0
|
||||
if ($filename.Length -le 300 -and $filename -cmatch '^/authentik/[A-Za-z0-9_./-]+\.py$' -and
|
||||
$function -cmatch '^[A-Za-z0-9_<>.]{1,120}$' -and
|
||||
[int]::TryParse([string]$frame['lineno'], [ref]$lineNumber) -and $lineNumber -gt 0)
|
||||
{
|
||||
$frames.Add(@{filename = $filename; line = $lineNumber; function = $function})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return @{exceptionTypes = @($types | Sort-Object); frames = @($frames.ToArray()); warnings = $warnings}
|
||||
}
|
||||
function Save-ProviderErrorSummary
|
||||
{
|
||||
<# .SYNOPSIS Reads bounded logs only from the exact owned synthetic provider. #>
|
||||
$ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project" --filter 'label=com.docker.compose.service=authentik-server')
|
||||
if ($LASTEXITCODE -ne 0 -or $ids.Count -ne 1 -or $ids[0] -cnotmatch '^[a-f0-9]{12,64}$') { return }
|
||||
$label = & docker inspect $ids[0] --format '{{index .Config.Labels "io.qrun.printing.dev"}}'
|
||||
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) { throw 'Provider diagnostic ownership mismatch.' }
|
||||
$lines = @(& docker logs --tail 1000 $ids[0] 2>&1 | ForEach-Object { $_.ToString() })
|
||||
if ($LASTEXITCODE -ne 0) { return }
|
||||
$summary = ConvertTo-ProviderErrorSummary $lines
|
||||
$lines = $null
|
||||
Assert-StateLocation $StateDirectory
|
||||
$summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $StateDirectory 'native-auth-server-errors.json')
|
||||
}
|
||||
function Wait-OwnedContainer([string]$Id, [int]$Seconds)
|
||||
{
|
||||
<# .SYNOPSIS Observes bounded completion without accessing application data. #>
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
|
||||
do
|
||||
{
|
||||
$state = & docker inspect $Id --format '{{.State.Running}}|{{.State.ExitCode}}'
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Owned fixture container disappeared.'
|
||||
}
|
||||
if ($state.StartsWith('false|', [StringComparison]::Ordinal))
|
||||
{
|
||||
return [int]$state.Split('|')[1]
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
throw 'Bounded fixture deadline expired; owned resources will be cleaned.'
|
||||
}
|
||||
function Assert-WorkloadClientSucceeded([string]$Id, [int]$ExitCode, [string]$Run, [string]$Reports)
|
||||
{
|
||||
<# .SYNOPSIS Preserves private client evidence and reports only fixed failure classifications. #>
|
||||
if ($ExitCode -eq 0) { return }
|
||||
$stage = 'unknown'
|
||||
$failureType = 'UnknownFailure'
|
||||
try
|
||||
{
|
||||
$path = Join-Path $Reports 'result.json'
|
||||
& docker cp "${Id}:/work/$Run/result.json" $path 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $path -PathType Leaf) -and (Get-Item -LiteralPath $path).Length -le 65536)
|
||||
{
|
||||
$result = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable
|
||||
if ($result -is [Collections.IDictionary] -and $result['success'] -ceq $false)
|
||||
{
|
||||
if ($result['stage'] -is [string] -and $result['stage'] -cin @('input', 'native_login_and_channels', 'workload', 'logout'))
|
||||
{
|
||||
$stage = $result['stage']
|
||||
}
|
||||
if ($result['error'] -is [string] -and $result['error'] -cin @('QqqSessionException', 'HttpRequestException', 'IOException', 'InvalidDataException', 'JsonException', 'OperationCanceledException', 'TaskCanceledException', 'UnauthorizedAccessException', 'WebSocketException', 'TimeoutException', 'InvalidOperationException'))
|
||||
{
|
||||
$failureType = $result['error']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
# Missing or malformed diagnostics must not hide the failed client or expose raw exception text.
|
||||
$stage = 'unknown'
|
||||
$failureType = 'UnknownFailure'
|
||||
}
|
||||
throw "Workload client failed at $stage ($failureType); see protected results."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($StateDirectory))
|
||||
{
|
||||
if ($Action -eq 'Stop')
|
||||
{
|
||||
throw 'Stop requires the state directory printed by Start.'
|
||||
}
|
||||
$base = if ($IsWindows)
|
||||
{
|
||||
Join-Path $env:LOCALAPPDATA 'QQQ/PrintingDevelopment'
|
||||
}
|
||||
else
|
||||
{
|
||||
Join-Path $HOME '.local/state/qqq-printing-development'
|
||||
}
|
||||
$StateDirectory = Join-Path $base ('environment-' + [Guid]::NewGuid().ToString('N'))
|
||||
}
|
||||
$StateDirectory = [IO.Path]::GetFullPath($StateDirectory)
|
||||
if ($StateDirectory.StartsWith($sourceRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase))
|
||||
{
|
||||
throw 'Keep secret state outside the repository.'
|
||||
}
|
||||
Assert-StateLocation $StateDirectory
|
||||
$ownerFile = Join-Path $StateDirectory 'owner.json'
|
||||
$script:envFile = Join-Path $StateDirectory 'compose.env'
|
||||
$createdState = $false
|
||||
if (Test-Path -LiteralPath $StateDirectory)
|
||||
{
|
||||
if (!(Test-Path -LiteralPath $ownerFile))
|
||||
{
|
||||
throw 'Existing state directory lacks this tool ownership record.'
|
||||
}
|
||||
$owner = Get-Content -LiteralPath $ownerFile -Raw | ConvertFrom-Json
|
||||
if ($owner.sourceRoot -cne $sourceRoot -or $owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$')
|
||||
{
|
||||
throw 'State ownership mismatch.'
|
||||
}
|
||||
$script:project = $owner.project
|
||||
$Authentik = [bool]$owner.authentik
|
||||
$savedNativeProfile = $null -ne $owner.PSObject.Properties['experimentalNativeAuth'] -and [bool]$owner.experimentalNativeAuth
|
||||
if ($ExperimentalNativeAuth -and !$savedNativeProfile)
|
||||
{
|
||||
throw 'An existing standard environment cannot be repurposed for experimental native authentication.'
|
||||
}
|
||||
$ExperimentalNativeAuth = $savedNativeProfile
|
||||
if ($ExperimentalNativeAuth -and $Action -eq 'Start')
|
||||
{
|
||||
throw 'Experimental native authentication is a disposable Test-only profile.'
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if ($Action -eq 'Stop')
|
||||
{
|
||||
throw 'Owned state directory does not exist.'
|
||||
}
|
||||
[IO.Directory]::CreateDirectory($StateDirectory) | Out-Null
|
||||
Protect-State $StateDirectory
|
||||
$createdState = $true
|
||||
$script:project = 'qbit-printing-dev-' + [Guid]::NewGuid().ToString('N')
|
||||
$commit = (& git -C $sourceRoot rev-parse HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$')
|
||||
{
|
||||
throw 'Cannot record source identity.'
|
||||
}
|
||||
foreach ($field in @{PRINTING_DEV_PROJECT = $project; DEV_SOURCE_DIR = $sourceRoot.Replace('\', '/'); SOURCE_COMMIT = $commit; QQQ_HTTP_PORT = "$QqqPort"; MINIO_HTTP_PORT = "$MinioPort"; MINIO_ROOT_USER = 'printing-development'; MINIO_ROOT_PASSWORD = (Get-FixtureSecret); QQQ_DATABASE_PASSWORD = (Get-FixtureSecret); QQQ_ADMIN_PASSWORD = (Get-FixtureSecret); QQQ_MANAGEMENT_TOKEN = (Get-FixtureSecret) }.GetEnumerator())
|
||||
{
|
||||
Set-FixtureEnvironment $field.Key $field.Value
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.yaml') -Destination (Join-Path $StateDirectory 'compose.yaml')
|
||||
if ($Authentik)
|
||||
{
|
||||
& (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project -Port $AuthentikPort
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.authentik.yaml') -Destination (Join-Path $StateDirectory 'compose.authentik.yaml')
|
||||
}
|
||||
if ($ExperimentalNativeAuth)
|
||||
{
|
||||
& (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.native-auth.yaml') -Destination (Join-Path $StateDirectory 'compose.native-auth.yaml')
|
||||
}
|
||||
@{project = $project; sourceRoot = $sourceRoot; sourceCommit = $commit; authentik = [bool]$Authentik; experimentalNativeAuth = [bool]$ExperimentalNativeAuth; createdAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath $ownerFile
|
||||
}
|
||||
$script:composeArguments = @('--project-name', $project, '--env-file', $envFile, '-f', (Join-Path $StateDirectory 'compose.yaml'))
|
||||
if ($Authentik)
|
||||
{
|
||||
$script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.authentik.yaml'))
|
||||
}
|
||||
if ($ExperimentalNativeAuth)
|
||||
{
|
||||
$script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.native-auth.yaml'))
|
||||
}
|
||||
if ($Action -eq 'Stop')
|
||||
{
|
||||
Stop-OwnedEnvironment
|
||||
Write-Output "Stopped owned development environment. Reports remain in $StateDirectory"
|
||||
return
|
||||
}
|
||||
|
||||
if ($ExperimentalNativeAuth)
|
||||
{
|
||||
$nativePassed = $false
|
||||
try
|
||||
{
|
||||
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'build', 'native-qqq', 'native-client')
|
||||
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'authentik-server', 'authentik-worker', 'native-tls')
|
||||
Invoke-Compose @('exec', '-T', 'native-tls', '/bin/sh', '/etc/caddy/copy-root.sh')
|
||||
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'native-qqq')
|
||||
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'run', '--rm', '--use-aliases', 'native-client')
|
||||
& (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action VerifyResult -StateDirectory $StateDirectory -ProjectName $project
|
||||
$nativePassed = $true
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!$nativePassed) { Save-ProviderErrorSummary }
|
||||
}
|
||||
finally { Stop-OwnedEnvironment }
|
||||
Write-Output "Protected experimental native-auth result: $(Join-Path $StateDirectory 'native-auth-result.json')"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($Action -eq 'Start')
|
||||
{
|
||||
$services = @('minio', 'qqq')
|
||||
$profiles = @()
|
||||
if ($Authentik)
|
||||
{
|
||||
$profiles = @('--profile', 'auth'); $services += @('authentik-server', 'authentik-worker')
|
||||
}
|
||||
try
|
||||
{
|
||||
Invoke-Compose ($profiles + @('up', '-d', '--build') + $services); & (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory
|
||||
}
|
||||
catch
|
||||
{
|
||||
if ($createdState)
|
||||
{
|
||||
Stop-OwnedEnvironment
|
||||
}; throw
|
||||
}
|
||||
Write-Output "Owned environment: $StateDirectory"
|
||||
Write-Output 'Read QQQ_ADMIN_PASSWORD in its protected compose.env; username is developer. No password is logged.'
|
||||
return
|
||||
}
|
||||
|
||||
$testRun = 'run-' + [Guid]::NewGuid().ToString('N')
|
||||
Set-FixtureEnvironment 'TEST_RUN' $testRun
|
||||
Set-FixtureEnvironment 'WORKLOAD_MODE' $(if ($Workload -eq 10000)
|
||||
{
|
||||
'burst'
|
||||
}
|
||||
else
|
||||
{
|
||||
'idle'
|
||||
})
|
||||
$reports = Join-Path $StateDirectory $testRun
|
||||
[IO.Directory]::CreateDirectory($reports) | Out-Null
|
||||
$passed = $false
|
||||
try
|
||||
{
|
||||
if ($Authentik)
|
||||
{
|
||||
Invoke-Compose @('--profile', 'auth', 'up', '-d', '--build', 'qqq', 'authentik-server', 'authentik-worker')
|
||||
& (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory
|
||||
& (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Test -StateDirectory $StateDirectory -ProjectName $project -Port (Get-RecordedAuthentikPort)
|
||||
}
|
||||
Invoke-Compose @('--profile', 'test', 'up', '-d', '--build', 'test-client')
|
||||
$client = (& docker compose @composeArguments ps -a -q test-client).Trim()
|
||||
$server = (& docker compose @composeArguments ps -a -q test-server).Trim()
|
||||
if ($client -cnotmatch '^[a-f0-9]{64}$' -or $server -cnotmatch '^[a-f0-9]{64}$')
|
||||
{
|
||||
throw 'Owned fixture identity missing.'
|
||||
}
|
||||
$clientExit = Wait-OwnedContainer $client 1200
|
||||
Assert-WorkloadClientSucceeded -Id $client -ExitCode $clientExit -Run $testRun -Reports $reports
|
||||
$serverExit = Wait-OwnedContainer $server 45
|
||||
foreach ($file in @('result.json', 'server-result.json', 'stopped.json', 's3/results.json', 's3/completed-checks.json'))
|
||||
{
|
||||
$destination = Join-Path $reports $file.Replace('/', '-')
|
||||
& docker cp "${server}:/work/$testRun/$file" $destination
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw 'Required sanitized fixture result missing.'
|
||||
}
|
||||
}
|
||||
if ($clientExit -ne 0 -or $serverExit -ne 0)
|
||||
{
|
||||
throw 'Fixture failed; see protected results.'
|
||||
}
|
||||
$clientResult = Get-Content (Join-Path $reports 'result.json') -Raw | ConvertFrom-Json
|
||||
$serverResult = Get-Content (Join-Path $reports 'server-result.json') -Raw | ConvertFrom-Json
|
||||
$s3Result = Get-Content (Join-Path $reports 's3-results.json') -Raw | ConvertFrom-Json
|
||||
if (!$clientResult.success -or !$serverResult.success -or !$s3Result.passed -or $clientResult.jobs -ne $Workload -or @($s3Result.checks).Count -ne 65)
|
||||
{
|
||||
throw 'Result invariants failed.'
|
||||
}
|
||||
$passed = $true
|
||||
}
|
||||
finally
|
||||
{
|
||||
& docker compose @composeArguments logs --no-color --no-log-prefix test-server test-client 2>&1 | Set-Content -LiteralPath (Join-Path $reports 'fixture.log')
|
||||
if ($ephemeral)
|
||||
{
|
||||
Stop-OwnedEnvironment
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert-OwnedResource
|
||||
Invoke-Compose @('--profile', 'test', 'rm', '--stop', '--force', 'test-client', 'test-server', 'minio-test')
|
||||
}
|
||||
@{passed = $passed; workload = $Workload; physicalPrinting = $false; tlsQualified = $false; finishedAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $reports 'execution.json')
|
||||
Write-Output "Protected developer-test results: $reports"
|
||||
}
|
||||
if (!$passed)
|
||||
{
|
||||
throw 'Development qualification did not pass.'
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prepares or verifies the opt-in, unpublished native-session Docker qualification.
|
||||
.DESCRIPTION
|
||||
Docker lifecycle belongs to Invoke-Dev.ps1. This helper creates only protected
|
||||
synthetic input and reads a bounded sanitized report. It never installs trust,
|
||||
uses host Maven caches, starts a browser, or operates the installed connector.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('Initialize', 'VerifyResult')][string] $Action,
|
||||
[Parameter(Mandatory)][string] $StateDirectory,
|
||||
[Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629'
|
||||
$clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c'
|
||||
$state = [IO.Path]::GetFullPath($StateDirectory)
|
||||
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
|
||||
if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison))
|
||||
{
|
||||
throw 'Experimental credentials must be outside the checkout.'
|
||||
}
|
||||
$envFile = Join-Path $state 'compose.env'
|
||||
$inputFile = Join-Path $state 'native-input.json'
|
||||
$resultFile = Join-Path $state 'native-auth-result.json'
|
||||
if(-not (Test-Path -LiteralPath $envFile -PathType Leaf))
|
||||
{
|
||||
throw 'The common entrypoint must initialize protected owned state first.'
|
||||
}
|
||||
|
||||
if($Action -eq 'VerifyResult')
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath $resultFile -PathType Leaf) -or (Get-Item -LiteralPath $resultFile).Length -gt 65536)
|
||||
{
|
||||
throw 'The experimental result is missing or exceeds its bounded size.'
|
||||
}
|
||||
try { $report = Get-Content -LiteralPath $resultFile -Raw | ConvertFrom-Json -AsHashtable }
|
||||
catch { throw 'The experimental result is malformed; its contents are withheld.' }
|
||||
$required = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied')
|
||||
if($report.status -cne 'PASS' -or $report.scope -cne 'DEVELOPMENT_ONLY_UNRELEASED' -or
|
||||
$report.coreSource -cne $coreSource -or $report.clientSource -cne $clientSource -or
|
||||
$report.project -cne $ProjectName -or @($required | Where-Object { $_ -notin $report.checks }).Count -ne 0)
|
||||
{
|
||||
throw 'The experimental native-session qualification did not pass its exact source and required checks.'
|
||||
}
|
||||
Write-Output 'Experimental native-session provider checks passed; the candidate is unpublished and no installed/browser-UI acceptance is claimed.'
|
||||
return
|
||||
}
|
||||
|
||||
if((Test-Path -LiteralPath $inputFile) -or (Get-Content -LiteralPath $envFile -Raw) -match '(?m)^NATIVE_')
|
||||
{
|
||||
throw 'Experimental native tests require a fresh owned environment; partial state is not reused.'
|
||||
}
|
||||
try
|
||||
{
|
||||
$auth = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json -AsHashtable
|
||||
if($auth.project -cne $ProjectName -or $auth.clientId -cne $ProjectName) { throw 'Wrong owner.' }
|
||||
}
|
||||
catch { throw 'Private Authentik bootstrap state is absent or does not match this project.' }
|
||||
$random = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
|
||||
$deniedPassword = [Convert]::ToBase64String($random).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
$issuer = "https://auth.printing.internal/application/o/$ProjectName/"
|
||||
$inputData = [ordered]@{
|
||||
project = $ProjectName
|
||||
coreSource = $coreSource
|
||||
clientSource = $clientSource
|
||||
providerOrigin = 'https://auth.printing.internal'
|
||||
qqqOrigin = 'https://qqq.printing.internal'
|
||||
issuer = $issuer
|
||||
callback = 'https://callback.printing.internal/oauth/callback'
|
||||
adminToken = $auth.bootstrapToken
|
||||
clientId = $auth.clientId
|
||||
clientSecret = $auth.clientSecret
|
||||
authorizedUsername = 'printing-authorized'
|
||||
authorizedPassword = $auth.userPassword
|
||||
deniedUsername = 'printing-denied'
|
||||
deniedPassword = $deniedPassword
|
||||
}
|
||||
$inputData | ConvertTo-Json | Set-Content -LiteralPath $inputFile -Encoding utf8
|
||||
@{ status = 'NOT_RUN'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $ProjectName } |
|
||||
ConvertTo-Json | Set-Content -LiteralPath $resultFile -Encoding utf8
|
||||
$values = [ordered]@{
|
||||
NATIVE_QQQ_DATABASE_PASSWORD = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
NATIVE_QQQ_REFRESH_KEY = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
NATIVE_AUTH_ISSUER = $issuer
|
||||
NATIVE_AUTH_CLIENT_ID = $auth.clientId
|
||||
NATIVE_AUTH_CLIENT_SECRET = $auth.clientSecret
|
||||
NATIVE_AUTH_INPUT_FILE = $inputFile.Replace('\', '/')
|
||||
NATIVE_AUTH_RESULT_FILE = $resultFile.Replace('\', '/')
|
||||
}
|
||||
foreach($entry in $values.GetEnumerator())
|
||||
{
|
||||
if($entry.Value -match '[\r\n'']') { throw 'Unsupported experimental environment value.' }
|
||||
}
|
||||
Add-Content -LiteralPath $envFile -Value @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" }) -Encoding utf8
|
||||
if(-not $IsWindows)
|
||||
{
|
||||
foreach($file in @($inputFile, $resultFile))
|
||||
{
|
||||
[IO.File]::SetUnixFileMode($file, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Local Docker development
|
||||
|
||||
Use this repository to start a separate QQQ application or run synthetic printing
|
||||
checks. Nothing imports an installed connector's pairing, accesses physical
|
||||
printers, or needs a cloud account. Docker Compose and PowerShell 7.4+ are required;
|
||||
Git identifies the checkout. Docker must support Linux/amd64 containers. The first
|
||||
build downloads pinned public images, Maven dependencies and the verified Windows
|
||||
Core source archive. Java and .NET run inside Docker; neither host SDK is required.
|
||||
|
||||
## Start, test and stop
|
||||
|
||||
From the repository root, the default command runs 65 S3 checks and 40 network
|
||||
jobs, saves protected reports, and removes its containers and synthetic volumes:
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test
|
||||
```
|
||||
|
||||
For a persistent development application, start it and keep the state-directory
|
||||
path printed by the command:
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Start
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -StateDirectory '<printed state directory>'
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Stop -StateDirectory '<printed state directory>'
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:19081`. The standard QQQ TABLE login username is `developer`.
|
||||
Read `QQQ_ADMIN_PASSWORD` from the generated, protected `compose.env` locally;
|
||||
never paste that file into an issue or commit it. There is no default password.
|
||||
The host provides native printing screens, settings, a dedicated MinIO document
|
||||
bucket and scheduled retention. No printer is enrolled automatically in this host.
|
||||
The test workload uses a separate disposable database and object store.
|
||||
|
||||
Add `-Authentik` when creating an environment or running a fresh test to include
|
||||
real Authentik/PostgreSQL. See [the provider checks and remaining native grant
|
||||
prerequisite](AUTHENTIK.md). Provider discovery does not establish successful OIDC
|
||||
user login or refresh; the current QQQ RC.20 API lacks that native-client grant.
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Workload 10000
|
||||
```
|
||||
|
||||
The optional 10,000-job run keeps admission limits enabled, uses eight synthetic
|
||||
clients across two tenants, and reports client process memory, latency and
|
||||
receipt counts. It is a bounded burst, not a day-long soak or physical throughput
|
||||
claim. The default 40-job run measures idle event delivery one job at a time.
|
||||
|
||||
## Isolation and cleanup
|
||||
|
||||
Each environment has a unique `qbit-printing-dev-<random>` Compose project and
|
||||
matching ownership labels. All published ports bind only to `127.0.0.1`: QQQ19081,
|
||||
MinIO19095 and optional Authentik19010. Override them with `-QqqPort`, `-MinioPort`
|
||||
and `-AuthentikPort` when creating a new environment. Occupied ports fail startup;
|
||||
the command never stops another service to free them. PostgreSQL is not published.
|
||||
|
||||
State defaults to the current user's application-data directory, outside the
|
||||
checkout, with private filesystem permissions. A custom `-StateDirectory` must
|
||||
also be outside the checkout. It holds generated fixture credentials, frozen
|
||||
Compose files, ownership records and sanitized result files. Docker administrators
|
||||
can inspect container configuration; use these credentials only for this fixture.
|
||||
|
||||
`Stop` validates container, network and volume ownership before removing that
|
||||
project's resources. **It deletes that environment's synthetic databases and
|
||||
objects.** It leaves reports and ordinary image/build caches in place and never
|
||||
runs a global prune. A fresh one-command `Test` performs this cleanup in `finally`,
|
||||
including on failure. Tests against an existing environment remove only their
|
||||
three test containers; finish with `Stop` to remove its disposable test volume and
|
||||
the persistent development environment. Do not edit its frozen ownership files.
|
||||
|
||||
Images are pinned by digest in Compose/Dockerfiles. Containers have bounded CPU
|
||||
and memory, no automatic restart, and no Docker socket. The default profile has
|
||||
no host credential or source mounts. QQQ/test containers additionally cap process
|
||||
counts. Builds copy
|
||||
only explicit source inputs. The QQQ and MinIO containers share their own network
|
||||
namespace so the existing loopback S3 backend can be used without relaxing
|
||||
production endpoint validation.
|
||||
|
||||
## What the results mean
|
||||
|
||||
- The S3 fixture exercises the actual QQQ `StorageAction`/S3 backend and AWS SDK,
|
||||
including a multipart-size document, hashes, tenant namespaces, immutable
|
||||
binding checks, retention and unrelated-object preservation.
|
||||
- The workload uses the real current Java server and pinned Windows Core client:
|
||||
native TABLE authentication, outbound WebSocket notifications, event-driven
|
||||
claims, document downloads and durable server receipts. A fake file printer
|
||||
verifies bytes and rejects duplicate submission. It cannot produce paper.
|
||||
- The fixture's client receipt journal is intentionally simple; these results do
|
||||
not replace production SQLite crash-recovery tests. MinIO fixture credentials
|
||||
are unrestricted within their owned store and do not qualify production IAM.
|
||||
- Connections here are HTTP/WebSocket on an isolated local project. TLS, provider
|
||||
browser consent/renewal, real deployment policies and hardware behavior are
|
||||
separate qualifications. No silent bearer-authentication fallback is added.
|
||||
|
||||
Results include `result.json`, `server-result.json`, `s3-results.json` and
|
||||
`execution.json` under a run directory. The persistent host also writes
|
||||
`qqq-result.json`; optional provider checks write `auth-result.json`. Treat raw
|
||||
fixture logs as private even though only synthetic accounts/documents are used.
|
||||
|
||||
The client Dockerfile pins commit `346fc285198908c47a2ee9db7b9adb53d5e88c3c`
|
||||
and verifies its source archive SHA-256 before compilation. The Java image builds
|
||||
this checkout, including local edits, and records its Git HEAD for context; a Git
|
||||
HEAD alone does not certify a dirty tree as that released artifact. This tooling
|
||||
is outside the production JAR and adds no application runtime dependency.
|
||||
|
||||
## Script regressions
|
||||
|
||||
With Pester 5 installed, the initializer and cleanup-ownership tests need no Docker
|
||||
or network. Disable Pester's unrelated registry fixture on Windows:
|
||||
|
||||
```powershell
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = './dev/tests'
|
||||
$configuration.Run.Exit = $true
|
||||
$configuration.TestRegistry.Enabled = $false
|
||||
Invoke-Pester -Configuration $configuration
|
||||
```
|
||||
|
||||
## Experimental native sign-in
|
||||
|
||||
The separate [native sign-in profile](native-auth/README.md) builds an explicitly
|
||||
selected upstream candidate and uses container-private TLS trust. It mounts only
|
||||
public Caddy configuration and specific protected fixture input/result files.
|
||||
It is opt-in, Test-only, and does not change the default released QQQ dependency.
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The development workflow runs the Pester and PowerShell analysis checks on
|
||||
Windows and Ubuntu for every pull request and main-branch push. Ubuntu also runs
|
||||
the real 40-job/S3 Docker smoke. The longer experimental native-auth profile is
|
||||
an explicit manual workflow option; it is not silently substituted for the
|
||||
released dependency baseline.
|
||||
@@ -0,0 +1,140 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
<# .SYNOPSIS Checks the owned local QQQ development host without enrolling or printing. #>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$StateDirectory)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$state = [IO.Path]::GetFullPath($StateDirectory)
|
||||
$owner = Get-Content -LiteralPath (Join-Path $state 'owner.json') -Raw | ConvertFrom-Json
|
||||
if ($owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$')
|
||||
{
|
||||
throw 'Expected an owned development environment.'
|
||||
}
|
||||
$values = @{}
|
||||
foreach ($line in Get-Content -LiteralPath (Join-Path $state 'compose.env'))
|
||||
{
|
||||
if ($line -match '^([A-Z_]+)=(.*)$')
|
||||
{
|
||||
$values[$Matches[1]] = $Matches[2]
|
||||
}
|
||||
}
|
||||
$port = [int]$values.QQQ_HTTP_PORT
|
||||
if ($port -lt 1024 -or $port -gt 65535 -or $port -in @(18081, 18086))
|
||||
{
|
||||
throw 'Expected a separate development port.'
|
||||
}
|
||||
$origin = "http://127.0.0.1:$port"
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$handler.UseProxy = $false
|
||||
$handler.AllowAutoRedirect = $false
|
||||
$handler.CookieContainer = [Net.CookieContainer]::new()
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$client.BaseAddress = [Uri]$origin
|
||||
$client.Timeout = [TimeSpan]::FromSeconds(15)
|
||||
$client.MaxResponseContentBufferSize = 1048576
|
||||
$client.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-development-check')
|
||||
$client.DefaultRequestHeaders.Add('Origin', $origin)
|
||||
$loggedIn = $false
|
||||
|
||||
function Invoke-DevelopmentRequest([string]$Path, [string]$Method = 'GET', [string]$Authorization)
|
||||
{
|
||||
<# .SYNOPSIS Sends one bounded native request, without returning credentials. #>
|
||||
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path)
|
||||
if ($Method -eq 'POST')
|
||||
{
|
||||
$request.Content = [Net.Http.StringContent]::new('{}', [Text.Encoding]::UTF8, 'application/json')
|
||||
}
|
||||
if ($Authorization)
|
||||
{
|
||||
$request.Headers.TryAddWithoutValidation('Authorization', $Authorization) | Out-Null
|
||||
}
|
||||
$response = $null
|
||||
try
|
||||
{
|
||||
$response = $client.SendAsync($request).GetAwaiter().GetResult()
|
||||
if (!$response.IsSuccessStatusCode)
|
||||
{
|
||||
throw 'Owned native endpoint returned an unsuccessful status.'
|
||||
}
|
||||
return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $response)
|
||||
{
|
||||
$response.Dispose()
|
||||
}; $request.Dispose()
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(90)
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
$version = (Invoke-DevelopmentRequest '/qqq/v1/version') | ConvertFrom-Json; break
|
||||
}
|
||||
catch [Net.Http.HttpRequestException]
|
||||
{
|
||||
if ([DateTime]::UtcNow -ge $deadline)
|
||||
{
|
||||
throw
|
||||
}; Start-Sleep -Seconds 1
|
||||
}
|
||||
} while ($true)
|
||||
if (@($version.extensions | Where-Object { $_.name -ceq 'printing' -and $_.version -ceq '2.0.0' }).Count -ne 1)
|
||||
{
|
||||
throw 'Expected current printing extension.'
|
||||
}
|
||||
$metadata = (Invoke-DevelopmentRequest '/qqq/v1/metaData/authentication') | ConvertFrom-Json
|
||||
if ($metadata.type -cne 'TABLE_BASED')
|
||||
{
|
||||
throw 'Development host must retain standard TABLE authentication.'
|
||||
}
|
||||
$basic = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('developer:' + $values.QQQ_ADMIN_PASSWORD))
|
||||
try
|
||||
{
|
||||
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/manageSession' -Method POST -Authorization $basic
|
||||
}
|
||||
finally
|
||||
{
|
||||
$basic = $null; $values = $null
|
||||
}
|
||||
$cookies = $handler.CookieContainer.GetCookies([Uri]$origin)
|
||||
$cookie = $cookies['sessionId']; if ($null -eq $cookie)
|
||||
{
|
||||
$cookie = $cookies['sessionUUID']
|
||||
}
|
||||
$loggedIn = $null -ne $cookie
|
||||
if (!$loggedIn -or !$cookie.HttpOnly)
|
||||
{
|
||||
throw 'Native HttpOnly session was not established.'
|
||||
}
|
||||
$identity = (Invoke-DevelopmentRequest '/qqq/v1/qbit/printing/session') | ConvertFrom-Json
|
||||
if ($identity.principalId -cne 'developer' -or $identity.tenantId -cne 'development')
|
||||
{
|
||||
throw 'Native development identity mismatch.'
|
||||
}
|
||||
$html = Invoke-DevelopmentRequest '/'
|
||||
if (!$html.Contains('<html', [StringComparison]::OrdinalIgnoreCase))
|
||||
{
|
||||
throw 'Native frontend asset missing.'
|
||||
}
|
||||
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/logout' -Method POST
|
||||
$loggedIn = $false
|
||||
@{passed = $true; nativeTableLogin = $true; principalAndTenantVerified = $true; frontendHtml = $true; logout = $true; workSubmitted = $false; observedUtc = [DateTime]::UtcNow.ToString('O') } |
|
||||
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'qqq-result.json')
|
||||
Write-Output 'Owned QQQ native login, identity, frontend asset and logout passed; no work submitted.'
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($loggedIn)
|
||||
{
|
||||
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/logout' -Method POST
|
||||
}
|
||||
$client.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
internal static class DiscoveryChecks
|
||||
{
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string Version, bool Accepted)[] cases =
|
||||
[
|
||||
("2.0.0", true), ("2.1.0", true), ("2.9.17", true),
|
||||
("1.9.0", false), ("3.0.0", false), ("2.1", false),
|
||||
("2.01.0", false), ("2.1.0-rc.1", false), ("not-a-version", false)
|
||||
];
|
||||
foreach((string version, bool expected) in cases)
|
||||
{
|
||||
using var handler = new DiscoveryHandler(version);
|
||||
using var client = new QqqSessionClient(new Uri("https://printing.example.invalid"), handler);
|
||||
bool accepted = false;
|
||||
try
|
||||
{
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client, CancellationToken.None);
|
||||
accepted = true;
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired) { }
|
||||
catch(InvalidDataException) { }
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
throw new InvalidDataException($"Discovery compatibility case {version} failed.");
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"Discovery compatibility checks passed: {cases.Length}.");
|
||||
}
|
||||
|
||||
private sealed class DiscoveryHandler(string version) : HttpMessageHandler
|
||||
{
|
||||
internal int Requests { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsolutePath != "/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization"))
|
||||
{
|
||||
throw new InvalidOperationException("Discovery must precede authentication.");
|
||||
}
|
||||
Requests++;
|
||||
string json = JsonSerializer.Serialize(new
|
||||
{
|
||||
apiVersion = "1.8.0",
|
||||
features = new[] { "version", "metaData", "processes", "processCancel", "extensions", "widgets" },
|
||||
clientChannels = new[] { "api" },
|
||||
extensions = new[] { new { name = "printing", version, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
internal static class Program {
|
||||
static int Completed; static readonly byte[] Expected=Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+new string(' ',980)+"^XZ");
|
||||
static async Task<int> Main(string[] args) {
|
||||
if(args is ["--check-discovery"]) { await DiscoveryChecks.RunAsync(); return 0; }
|
||||
string folder=Path.GetFullPath(args[0]); string stage="input";
|
||||
using var timeout=new CancellationTokenSource(TimeSpan.FromMinutes(18));
|
||||
List<Lane> lanes=[]; long peak=0; var timer=Stopwatch.StartNew();
|
||||
try {
|
||||
using JsonDocument input=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder,"input.json")));
|
||||
stage="native_login_and_channels";
|
||||
bool idle=input.RootElement.GetProperty("idle").GetBoolean(); int perLane=input.RootElement.GetProperty("jobsPerLane").GetInt32(); int total=perLane*8; int index=0;
|
||||
foreach(var value in input.RootElement.GetProperty("lanes").EnumerateArray()) {
|
||||
Uri origin=new(value.GetProperty("origin").GetString()!);
|
||||
Require(origin.IsLoopback && origin.Port is not (18081 or 18086),"disposable origin");
|
||||
var client=new QqqSessionClient(origin,allowLoopbackHttp:true);
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client,timeout.Token);
|
||||
await client.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!,value.GetProperty("password").GetString()!,timeout.Token);
|
||||
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!,value.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);
|
||||
var binding=await client.GetSessionIdentityAsync(timeout.Token);
|
||||
Require(binding.TenantId==value.GetProperty("tenant").GetString(),"authoritative tenant");
|
||||
var connection=await Connection.Open(client,timeout.Token);
|
||||
lanes.Add(new(client,connection,Path.Combine(folder,"lane-"+index++),perLane,folder));
|
||||
}
|
||||
Require(lanes.Count==8,"eight lanes");
|
||||
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
|
||||
var foreign=input.RootElement.GetProperty("lanes")[4]; bool denied=false;
|
||||
using(var outsider=new QqqSessionClient(new Uri(input.RootElement.GetProperty("lanes")[0].GetProperty("origin").GetString()!),allowLoopbackHttp:true)) {
|
||||
await outsider.DiscoverAsync(timeout.Token);await outsider.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await outsider.SignInWithPasswordAsync("operator",input.RootElement.GetProperty("lanes")[0].GetProperty("password").GetString()!,timeout.Token);
|
||||
try {await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!,foreign.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);}catch(QqqSessionException){denied=true;}
|
||||
Require(denied,"cross tenant proof refused");await outsider.SignOutAsync(timeout.Token);
|
||||
}
|
||||
stage="workload";
|
||||
var tasks=lanes.Select(lane=>lane.Run(timeout.Token)).ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"ready"),"ready",timeout.Token);
|
||||
Task all=Task.WhenAll(tasks);
|
||||
while(!all.IsCompleted) {
|
||||
peak=Math.Max(peak,Process.GetCurrentProcess().WorkingSet64);
|
||||
await Task.WhenAny(all,Task.Delay(1000,timeout.Token));timeout.Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
await all;
|
||||
Require(lanes.Sum(l=>l.Printer.Count)==total,"exact fake submission count");
|
||||
Require(lanes.All(l=>l.Journal.Pending().Count==0),"no unacknowledged receipts");
|
||||
stage="logout";foreach(var lane in lanes)await lane.Client.SignOutAsync(timeout.Token);
|
||||
double[] latencies=lanes.SelectMany(l=>l.Latencies).Order().ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new {
|
||||
success=true,jobs=total,idle,tenants=2,devices=8,elapsedSeconds=timer.Elapsed.TotalSeconds,jobsPerSecond=total/timer.Elapsed.TotalSeconds,
|
||||
peakClientWorkingSetBytes=peak,p50EnqueueToAckMillis=latencies[total/2],p95EnqueueToAckMillis=latencies[(int)(total*0.95)],p99EnqueueToAckMillis=latencies[(int)(total*0.99)],
|
||||
realCoreWorker=true,fakePrinterBackend=true,fixtureJournal=true,serverReceiptsDurable=true,crossTenantProofRefused=denied,
|
||||
submissionReplays=idle?0:400,receiptReplays=idle?0:400,notificationToClaimMillis=lanes.SelectMany(l=>l.ClaimLatency).ToArray(),notificationToDownloadCompletedMillis=lanes.SelectMany(l=>l.DownloadLatency).ToArray(),periodicJobPolling=false,transport="HTTP/WS loopback; no TLS qualification",realPrinterCalls=0,
|
||||
runtime=Environment.Version.ToString(),coreVersion=typeof(PrintWorker).Assembly.GetName().Version?.ToString()
|
||||
},new JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");return 0;
|
||||
} catch(Exception error) {
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new{success=false,stage,error=error.GetType().Name,reason=error is InvalidDataException?error.Message:null,completed=lanes.Sum(l=>l.Printer.Count)}));
|
||||
Console.Error.WriteLine("FAIL "+stage+": "+error.GetType().Name);return 1;
|
||||
} finally {
|
||||
foreach(var lane in lanes)await lane.DisposeAsync();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"done"),"done");
|
||||
}
|
||||
}
|
||||
static void Require(bool ok,string reason){if(!ok)throw new InvalidDataException(reason);}
|
||||
sealed class Lane: IAsyncDisposable {
|
||||
public QqqSessionClient Client{get;} public Connection Connection{get;}
|
||||
public FakePrinter Printer{get;}=new(); public Journal Journal{get;} public List<double> Latencies{get;}=[];
|
||||
readonly PrintApiClient api;readonly PrintWorker worker; readonly int target; readonly string root; long wakeAt; public List<double> ClaimLatency{get;}=[]; public List<double> DownloadLatency{get;}=[];
|
||||
public Lane(QqqSessionClient client,Connection connection,string folder,int target,string root) {
|
||||
Client=client;Connection=connection;this.target=target;this.root=root;Printer.OnDownload=()=>DownloadLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);Directory.CreateDirectory(folder);Journal=new(folder);
|
||||
api=PrintApiClient.ForSession(client).WithRealtime(connection.Channel,connection.Settings);
|
||||
worker=new PrintWorker(api,Printer,Journal,options:new PrintWorkerOptions{Settings=connection.Settings,StagingDirectory=Path.Combine(folder,"staging"),SynchronizeInventory=false,RecoverIncomplete=false,ActiveJobChanged=job=>{if(job is not null)ClaimLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);}});
|
||||
}
|
||||
public async Task Run(CancellationToken token) {
|
||||
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task ping=Liveness(stop.Token);
|
||||
try {
|
||||
while(Printer.Count<target) {
|
||||
wakeAt=await Connection.Wakes.Reader.ReadAsync(token);
|
||||
while(Printer.Count<target && await worker.RunOnceAsync(token)) {
|
||||
Require(Journal.Last.Event.Status=="SPOOLED","synthetic job terminal status");
|
||||
Latencies.Add((DateTimeOffset.UtcNow-DateTimeOffset.Parse(Journal.LastJob!.CreatedAt,CultureInfo.InvariantCulture)).TotalMilliseconds);
|
||||
await File.WriteAllTextAsync(Path.Combine(root,"completed-"+Interlocked.Increment(ref Completed)),"acknowledged",token);
|
||||
if(Printer.Count%25==0)await api.EventAsync(Journal.Last.JobId,Journal.Last.Event,token);
|
||||
}
|
||||
}
|
||||
} finally {await stop.CancelAsync();try{await ping;}catch(OperationCanceledException){}}
|
||||
}
|
||||
async Task Liveness(CancellationToken token) {
|
||||
using var tick=new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
|
||||
while(await tick.WaitForNextTickAsync(token))await Connection.Channel.SendAsync("PING",new{},token:token);
|
||||
}
|
||||
public async ValueTask DisposeAsync(){api.Dispose();await Connection.DisposeAsync();Client.Dispose();Journal.Dispose();}
|
||||
}
|
||||
sealed class FakePrinter:IFilePrinterBackend {
|
||||
readonly HashSet<string> seen=new(StringComparer.Ordinal);public int Count=>seen.Count; public Action? OnDownload{get;set;}
|
||||
public IReadOnlyList<PrinterInfo> Discover()=>[new("synthetic","No physical printer","fixture","none","READY",["ZPL"])];
|
||||
public async Task<string> PrintFileAsync(PrintJob job,string path,CancellationToken token) {OnDownload?.Invoke();return await PrintAsync(job,await File.ReadAllBytesAsync(path,token),token);}
|
||||
public Task<string> PrintAsync(PrintJob job,byte[] content,CancellationToken token) {
|
||||
Require(content.AsSpan().SequenceEqual(Expected),"downloaded bytes exact");Require(seen.Add(job.JobId),"no duplicate physical action");return Task.FromResult("synthetic-"+Count);
|
||||
}
|
||||
}
|
||||
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
|
||||
sealed class Journal: IJobJournal,IDisposable {
|
||||
readonly Dictionary<string,string> states=new(StringComparer.Ordinal);readonly Dictionary<string,(string,PrintEvent)> pending=new(StringComparer.Ordinal);readonly StreamWriter log;
|
||||
public PrintJob? LastJob{get;private set;}public (string JobId,PrintEvent Event) Last{get;private set;}
|
||||
public Journal(string folder){log=new StreamWriter(new FileStream(Path.Combine(folder,"synthetic-receipts.jsonl"),FileMode.CreateNew,FileAccess.Write,FileShare.Read)){AutoFlush=true};}
|
||||
public void TrackJob(PrintJob job,string? printer){LastJob=job;}
|
||||
public string? GetState(string id)=>states.GetValueOrDefault(id);
|
||||
public void Record(string id,PrintEvent evt){log.WriteLine(JsonSerializer.Serialize(new{jobId=id,@event=evt}));states[id]=evt.Status;pending.Add(evt.EventId,(id,evt));Last=(id,evt);}
|
||||
public IReadOnlyList<(string JobId,PrintEvent Event)> Pending()=>pending.Values.ToList();
|
||||
public void Acknowledge(string id){pending.Remove(id);}
|
||||
public IReadOnlyList<(string JobId,string State)> Incomplete()=>[];
|
||||
public void Dispose()=>log.Dispose();
|
||||
}
|
||||
sealed class Connection:IAsyncDisposable {
|
||||
public required PrintingRealtimeChannel Channel{get;init;}public required Task Reader{get;init;}public required CancellationTokenSource Lifetime{get;init;}
|
||||
public required Channel<RealtimeEnvelope> Messages{get;init;}public required Channel<long> Wakes{get;init;}public RealtimeSettings Settings{get;private set;}=new();
|
||||
public static async Task<Connection> Open(QqqSessionClient client,CancellationToken token) {
|
||||
var lifetime=CancellationTokenSource.CreateLinkedTokenSource(token);WebSocket socket=await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20),TimeSpan.FromSeconds(60),token);
|
||||
var channel=new PrintingRealtimeChannel(socket,new RealtimeSettings());var messages=System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
|
||||
var wakes=System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1){FullMode=BoundedChannelFullMode.DropWrite});
|
||||
Task reader=channel.RunAsync(e=>{
|
||||
if(e.Type=="JOB_AVAILABLE"){wakes.Writer.TryWrite(Stopwatch.GetTimestamp());return;}
|
||||
if(e.Type is "SETTINGS" or "DESIRED_STATE") {long revision=e.Payload.GetProperty("revision").GetInt64();if(revision!=0)throw new InvalidDataException("Unexpected policy change");return;}
|
||||
if(e.Type is "PONG" or "ACK")return;
|
||||
if(!messages.Writer.TryWrite(e))throw new IOException("Fixture envelope inbox full");
|
||||
},lifetime.Token);
|
||||
_=reader.ContinueWith(t=>{wakes.Writer.TryComplete(t.Exception);messages.Writer.TryComplete(t.Exception);},TaskScheduler.Default);
|
||||
var result=new Connection{Channel=channel,Reader=reader,Lifetime=lifetime,Messages=messages,Wakes=wakes};
|
||||
await channel.SendAsync("HELLO",new{agentVersion=PrintApiClient.AgentVersion,capabilities=new[]{"realtime-v1","REFRESH_INVENTORY","CANCEL_SPOOL"},lastAppliedRevision=0,lastSettingsRevision=0,limits=RealtimeSettings.ClientLimits},token:token);
|
||||
RealtimeEnvelope welcome=await messages.Reader.ReadAsync(token);Require(welcome.Type=="WELCOME","welcome");
|
||||
result.Settings=welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;result.Settings.Validate();channel.UpdateSettings(result.Settings);
|
||||
await channel.SendAsync("STATE_APPLIED",new{lastAppliedRevision=welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),lastSettingsRevision=result.Settings.Revision},acknowledged:true,token:token);return result;
|
||||
}
|
||||
public async ValueTask DisposeAsync(){await Lifetime.CancelAsync();try{await Reader;}catch(Exception e)when(e is OperationCanceledException or IOException or WebSocketException){}Channel.Dispose();Lifetime.Dispose();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
internal static class WorkloadDiscovery
|
||||
{
|
||||
internal static async Task<QqqApiDiscovery> DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken);
|
||||
// Core discovery validates strict stable SemVer; realtime uses the same major gate as ConnectPrintingAsync.
|
||||
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
|
||||
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
|
||||
{
|
||||
throw new InvalidDataException("Native realtime printing API required.");
|
||||
}
|
||||
return discovery;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Optional real provider reference. No Docker socket or published database port.
|
||||
services:
|
||||
authentik-postgres:
|
||||
profiles: [auth]
|
||||
platform: linux/amd64
|
||||
image: postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first}
|
||||
volumes:
|
||||
- authentik-database:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "pg_isready -U authentik -d authentik"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
|
||||
authentik-server:
|
||||
profiles: [auth]
|
||||
platform: linux/amd64
|
||||
image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
command: server
|
||||
depends_on:
|
||||
authentik-postgres:
|
||||
condition: service_healthy
|
||||
environment: &authentik-environment
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?Run the development initializer first}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
|
||||
AUTHENTIK_LOG_LEVEL: warning
|
||||
ports:
|
||||
- "127.0.0.1:${AUTHENTIK_HTTP_PORT:-19010}:9000"
|
||||
volumes:
|
||||
- authentik-data:/data
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
|
||||
shm_size: 256mb
|
||||
mem_limit: 1536m
|
||||
cpus: 2.0
|
||||
|
||||
authentik-worker:
|
||||
profiles: [auth]
|
||||
platform: linux/amd64
|
||||
image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
command: worker
|
||||
depends_on:
|
||||
authentik-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *authentik-environment
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: ${AUTHENTIK_BOOTSTRAP_PASSWORD_HASH:?Run the development initializer first}
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?Run the development initializer first}
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: developer@example.invalid
|
||||
volumes:
|
||||
- authentik-data:/data
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
|
||||
shm_size: 256mb
|
||||
mem_limit: 1536m
|
||||
cpus: 2.0
|
||||
|
||||
volumes:
|
||||
authentik-database:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
|
||||
authentik-data:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Opt-in unpublished core qualification. No service is a replacement for the
|
||||
# released RC.20 TABLE development host or an installed printing deployment.
|
||||
services:
|
||||
authentik-server:
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- auth.printing.internal
|
||||
- qqq.printing.internal
|
||||
- callback.printing.internal
|
||||
|
||||
native-tls:
|
||||
profiles: [native-auth]
|
||||
image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
|
||||
platform: linux/amd64
|
||||
network_mode: service:authentik-server
|
||||
depends_on: [authentik-server]
|
||||
volumes:
|
||||
- ${DEV_SOURCE_DIR}/dev/native-auth/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${DEV_SOURCE_DIR}/dev/native-auth/copy-root.sh:/etc/caddy/copy-root.sh:ro
|
||||
- native-ca:/data
|
||||
- native-trust:/trust
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
cpus: 1
|
||||
mem_limit: 256m
|
||||
|
||||
native-qqq:
|
||||
profiles: [native-auth]
|
||||
build:
|
||||
context: ${DEV_SOURCE_DIR}
|
||||
dockerfile: dev/native-auth/Dockerfile.server
|
||||
image: ${PRINTING_DEV_PROJECT}-native-server:local
|
||||
environment:
|
||||
NATIVE_QQQ_PORT: "19082"
|
||||
NATIVE_QQQ_ORIGIN: https://qqq.printing.internal
|
||||
NATIVE_QQQ_DATABASE_PASSWORD: ${NATIVE_QQQ_DATABASE_PASSWORD:?Initialize experimental state first}
|
||||
NATIVE_QQQ_REFRESH_KEY: ${NATIVE_QQQ_REFRESH_KEY:?Initialize experimental state first}
|
||||
NATIVE_AUTH_ISSUER: ${NATIVE_AUTH_ISSUER:?Initialize experimental state first}
|
||||
NATIVE_AUTH_CLIENT_ID: ${NATIVE_AUTH_CLIENT_ID:?Initialize experimental state first}
|
||||
NATIVE_AUTH_CLIENT_SECRET: ${NATIVE_AUTH_CLIENT_SECRET:?Initialize experimental state first}
|
||||
NATIVE_AUTH_REDIRECT_URI: https://callback.printing.internal/oauth/callback
|
||||
volumes:
|
||||
- native-trust:/trust:ro
|
||||
- native-qqq-data:/data
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
cpus: 2
|
||||
mem_limit: 1g
|
||||
|
||||
native-client:
|
||||
profiles: [native-auth]
|
||||
build:
|
||||
context: ${DEV_SOURCE_DIR}
|
||||
dockerfile: dev/native-auth/Dockerfile.client
|
||||
image: ${PRINTING_DEV_PROJECT}-native-client:local
|
||||
volumes:
|
||||
- native-trust:/trust:ro
|
||||
- ${NATIVE_AUTH_INPUT_FILE:?Initialize experimental state first}:/work/input.json:ro
|
||||
- ${NATIVE_AUTH_RESULT_FILE:?Initialize experimental state first}:/work/result.json
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
cpus: 2
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
native-ca:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
native-trust:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
native-qqq-data:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Every published port is bound to host loopback; no live printing configuration is imported.
|
||||
x-owned: &owned
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?owned project required}
|
||||
restart: "no"
|
||||
cpus: 2
|
||||
mem_limit: 1g
|
||||
pids_limit: 256
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=128m,mode=1777"]
|
||||
|
||||
services:
|
||||
minio:
|
||||
<<: *owned
|
||||
image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56
|
||||
user: "0:0"
|
||||
command: [server, /data, --address, ":9000"]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?fixture secret required}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?fixture secret required}
|
||||
volumes: [minio-data:/data]
|
||||
ports:
|
||||
- "127.0.0.1:${MINIO_HTTP_PORT:-19095}:9000"
|
||||
- "127.0.0.1:${QQQ_HTTP_PORT:-19081}:${QQQ_HTTP_PORT:-19081}"
|
||||
qqq:
|
||||
<<: *owned
|
||||
build:
|
||||
context: ${DEV_SOURCE_DIR:?source required}
|
||||
dockerfile: dev/Dockerfile.server
|
||||
args:
|
||||
SOURCE_COMMIT: ${SOURCE_COMMIT:?source identity required}
|
||||
image: ${PRINTING_DEV_PROJECT}-server:local
|
||||
network_mode: service:minio
|
||||
depends_on: [minio]
|
||||
environment:
|
||||
QQQ_HTTP_PORT: ${QQQ_HTTP_PORT:-19081}
|
||||
QQQ_DATABASE_PASSWORD: ${QQQ_DATABASE_PASSWORD:?fixture secret required}
|
||||
QQQ_ADMIN_PASSWORD: ${QQQ_ADMIN_PASSWORD:?fixture secret required}
|
||||
QQQ_MANAGEMENT_TOKEN: ${QQQ_MANAGEMENT_TOKEN:?fixture secret required}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes: [qqq-data:/data]
|
||||
minio-test:
|
||||
<<: *owned
|
||||
profiles: [test]
|
||||
image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56
|
||||
user: "0:0"
|
||||
command: [server, /data, --address, ":9000"]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m,mode=1777", "/data:rw,noexec,nosuid,size=900m,mode=1777"]
|
||||
test-server:
|
||||
<<: *owned
|
||||
profiles: [test]
|
||||
build:
|
||||
context: ${DEV_SOURCE_DIR}
|
||||
dockerfile: dev/Dockerfile.server
|
||||
args:
|
||||
SOURCE_COMMIT: ${SOURCE_COMMIT}
|
||||
image: ${PRINTING_DEV_PROJECT}-server:local
|
||||
network_mode: service:minio-test
|
||||
depends_on: [minio-test]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
TEST_RUN: ${TEST_RUN:-run-uninitialized}
|
||||
WORKLOAD_MODE: ${WORKLOAD_MODE:-idle}
|
||||
volumes: [test-data:/work]
|
||||
entrypoint: [/bin/sh, /app/run-tests.sh]
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, 'test -f "/work/$$TEST_RUN/input.json"']
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 120
|
||||
test-client:
|
||||
<<: *owned
|
||||
profiles: [test]
|
||||
build:
|
||||
context: ${DEV_SOURCE_DIR}
|
||||
dockerfile: dev/Dockerfile.client
|
||||
image: ${PRINTING_DEV_PROJECT}-client:local
|
||||
network_mode: service:minio-test
|
||||
depends_on:
|
||||
test-server:
|
||||
condition: service_healthy
|
||||
volumes: [test-data:/work]
|
||||
command: ["/work/${TEST_RUN:-run-uninitialized}"]
|
||||
|
||||
volumes:
|
||||
minio-data:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
qqq-data:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
test-data:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
networks:
|
||||
default:
|
||||
labels:
|
||||
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
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.time.Duration;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/** Docker-only developer host. Compose publishes its port to host loopback only. */
|
||||
public final class DevHost extends PrintingLocalHost
|
||||
{
|
||||
private static final String TABLE = "printing_documents";
|
||||
|
||||
private DevHost() throws Exception
|
||||
{
|
||||
super(new PrintingLocalSettings("jdbc:h2:file:/data/printing", "sa", required("QQQ_DATABASE_PASSWORD"), "development",
|
||||
Integer.parseInt(required("QQQ_HTTP_PORT")), required("QQQ_MANAGEMENT_TOKEN"), "developer", required("QQQ_ADMIN_PASSWORD")));
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
waitStorage();
|
||||
if(args.length == 1 && "--wait-storage".equals(args[0]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
DevHost host = new DevHost();
|
||||
QApplicationLauncher.run(host, new QApplicationLauncherConfig().withServerCustomizer(server ->
|
||||
{
|
||||
host.configure(server, true);
|
||||
// Container interface only. The immutable Compose mapping binds host 127.0.0.1.
|
||||
var original = server.getJavalinConfigCustomizer();
|
||||
server.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
original.accept(config);
|
||||
config.jetty.host = "0.0.0.0";
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = super.defineQInstance();
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_development_storage").withBucketName("printing-development")
|
||||
.withEndpoint("http://127.0.0.1:9000").withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(required("MINIO_ROOT_USER")).withSecretKey(required("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE).withFileNameFieldName("id").withContentsFieldName("content"))
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
var storage = S3ClientFactory.getClient(backend);
|
||||
{
|
||||
try
|
||||
{
|
||||
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
catch(S3Exception missing)
|
||||
{
|
||||
if(missing.statusCode() != 404)
|
||||
{
|
||||
throw missing;
|
||||
}
|
||||
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
}
|
||||
PrintingQBitConfig config = instance.getQBits().values().stream().map(value -> value.getConfig())
|
||||
.filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
|
||||
config.withStorageTableName(TABLE);
|
||||
config.getService().configureStorage(instance, TABLE);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void waitStorage() throws Exception
|
||||
{
|
||||
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build())
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
|
||||
while(System.nanoTime() < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:9000/minio/health/live"))
|
||||
.timeout(Duration.ofSeconds(2)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode() == 200)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException notReady)
|
||||
{
|
||||
// Only the fixture startup window retries; application work is never retried here.
|
||||
}
|
||||
Thread.sleep(200);
|
||||
}
|
||||
throw new IllegalStateException("Owned MinIO startup timed out");
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Missing development environment setting: " + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import com.kingsrook.qbits.printing.*;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import org.json.*;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.*;
|
||||
|
||||
/** Non-listening synthetic harness. Never routes work to a Windows device. */
|
||||
public final class S3PrintingQualification {
|
||||
private static final String TABLE = "printing_documents";
|
||||
private static final JSONArray CHECKS = new JSONArray();
|
||||
private static final JSONArray DOCUMENTS = new JSONArray();
|
||||
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
|
||||
private static DriverDataSource database;
|
||||
private static S3Client s3;
|
||||
private static String bucket;
|
||||
private static String otherBucket;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path output = Path.of(args[0]).toAbsolutePath();
|
||||
Files.createDirectories(output);
|
||||
String run = args[2];
|
||||
bucket = "printing-fixture-" + run;
|
||||
otherBucket = "printing-canary-" + run;
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
|
||||
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
QInstance instance = new QInstance();
|
||||
// Required metadata only: this harness opens no HTTP endpoint or user session.
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
|
||||
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
|
||||
try {
|
||||
s3 = S3ClientFactory.getClient(backend);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
|
||||
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
|
||||
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
|
||||
PrintingService a = service(instance, "fixture-tenant-a");
|
||||
PrintingService b = service(instance, "fixture-tenant-b");
|
||||
String deviceA = enroll(a);
|
||||
String deviceB = enroll(b);
|
||||
byte[] pdf = PrintingTestDocuments.pdf();
|
||||
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] large = new byte[6 * 1024 * 1024 + 71];
|
||||
Arrays.fill(large, (byte)'A');
|
||||
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
|
||||
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
|
||||
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
|
||||
finish(a, deviceA, pdfJob, "SPOOLED");
|
||||
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
|
||||
finish(a, deviceA, zplJob, "SPOOLED");
|
||||
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
|
||||
finish(a, deviceA, largeJob, "SPOOLED");
|
||||
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
|
||||
finish(b, deviceB, foreignJob, "SPOOLED");
|
||||
String foreignRef = reference(foreignJob);
|
||||
String ownRef = reference(pdfJob);
|
||||
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
|
||||
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
|
||||
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
|
||||
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
|
||||
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
|
||||
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
|
||||
finish(a, deviceA, failed, "FAILED");
|
||||
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
|
||||
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
|
||||
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
|
||||
String old = Instant.now().minusSeconds(400L * 86400).toString();
|
||||
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active)) {
|
||||
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
|
||||
}
|
||||
int before = objectCount();
|
||||
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
|
||||
check(objectCount() == before - 2, "object count reflects exact two removals");
|
||||
missing(OBJECT_KEYS.get(ownRef));
|
||||
missing(OBJECT_KEYS.get(reference(largeJob)));
|
||||
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
|
||||
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
|
||||
check(objectCount() == before - 2, "post-retention replay creates no object");
|
||||
for(String id : List.of(zplJob, foreignJob, failed, unknown, active)) {
|
||||
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
|
||||
"protected object retained: " + status(id));
|
||||
}
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
|
||||
check(a.runRetention() == 0, "repeated cleanup is idempotent");
|
||||
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
|
||||
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
|
||||
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
|
||||
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
|
||||
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
|
||||
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
|
||||
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
|
||||
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
|
||||
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
|
||||
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
|
||||
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
|
||||
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
|
||||
} finally {
|
||||
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
|
||||
S3ClientFactory.clearCache();
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
private static PrintingService service(QInstance instance, String tenant) throws Exception {
|
||||
PrintingService service = new PrintingService(database, tenant);
|
||||
service.initialize(); service.configureStorage(instance, TABLE); return service;
|
||||
}
|
||||
private static String enroll(PrintingService service) throws Exception {
|
||||
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
|
||||
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
|
||||
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
|
||||
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
|
||||
service.enablePrinter(id, "synthetic-never-dispatched", true); return id;
|
||||
}
|
||||
private static JSONObject request(String device, String key, String format, byte[] bytes) {
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
|
||||
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
}
|
||||
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception {
|
||||
JSONObject request = request(device, key, format, bytes);
|
||||
String id = service.submit(request).getString("jobId");
|
||||
int count = objectCount();
|
||||
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
|
||||
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
|
||||
byte[] actual = service.content(device, id);
|
||||
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
|
||||
String ref = reference(id);
|
||||
String actualKey = objectKey(ref);
|
||||
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
|
||||
check(head.contentLength() == bytes.length, key + " object length exact");
|
||||
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
|
||||
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1, id); try(var rows = statement.executeQuery()) { check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy"); }
|
||||
}
|
||||
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
|
||||
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
|
||||
return id;
|
||||
}
|
||||
private static void finish(PrintingService service, String device, String id, String status) throws Exception {
|
||||
for(String phase : List.of("RECEIVED", "SUBMITTING", status)) service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
|
||||
}
|
||||
private static String sha(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
private static int objectCount() { return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size(); }
|
||||
private static String objectKey(String reference) {
|
||||
String known = OBJECT_KEYS.get(reference);
|
||||
if(known != null) return known;
|
||||
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
|
||||
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
|
||||
if(matches.size() != 1) throw new AssertionError("Expected exactly one synthetic object for stored reference");
|
||||
String key = matches.getFirst();
|
||||
String prefix = key.substring(0, key.length() - reference.length());
|
||||
if(!prefix.isEmpty() && !prefix.matches("\\\\+")) throw new AssertionError("Unexpected physical S3 key prefix");
|
||||
OBJECT_KEYS.put(reference, key);
|
||||
return key;
|
||||
}
|
||||
private static String reference(String id) throws Exception { return field(id, "content_reference"); }
|
||||
private static String status(String id) throws Exception { return field(id, "status"); }
|
||||
private static String field(String id, String field) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1,id); try(var rows=statement.executeQuery()) { if(!rows.next()) throw new AssertionError("Missing synthetic job"); return rows.getString(1); }
|
||||
}
|
||||
}
|
||||
private static void execute(String sql, String... args) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement(sql)) { for(int i=0; i<args.length; i++) statement.setString(i+1,args[i]); statement.executeUpdate(); }
|
||||
}
|
||||
private static void check(boolean pass, String label) { if(!pass) throw new AssertionError(label); CHECKS.put(label); }
|
||||
private static void missing(String ref) {
|
||||
try { s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build()); throw new AssertionError("Expected removed object"); }
|
||||
catch(S3Exception exception) { check(exception.statusCode()==404,"eligible object absent from S3 after cleanup"); }
|
||||
}
|
||||
@FunctionalInterface private interface Checked { void run() throws Exception; }
|
||||
private static void expectStatus(int status, Checked operation, String label) throws Exception {
|
||||
try { operation.run(); throw new AssertionError(label); } catch(PrintingException exception) { check(exception.getStatus()==status,label); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package com.kingsrook.qbits.printing;
|
||||
import java.nio.file.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qqq.middleware.javalin.*;
|
||||
import io.javalin.Javalin;
|
||||
import org.json.*;
|
||||
/** Fixture only: real checkout host, synthetic SDK producer, no physical printer. */
|
||||
public final class WorkloadFixture {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path dir=Path.of(args[0]).toAbsolutePath();
|
||||
if(!dir.getFileName().toString().startsWith("run-"))throw new IllegalArgumentException("Owned run directory required");
|
||||
String jdbc="jdbc:h2:file:"+dir.resolve("synthetic").toString().replace('\\','/')+";DB_CLOSE_DELAY=-1";
|
||||
String password="synthetic-"+UUID.randomUUID();
|
||||
List<QApplicationLauncher> launches=new ArrayList<>(); List<PrintingService> services=new ArrayList<>();
|
||||
List<String> devices=new ArrayList<>(); JSONArray inputs=new JSONArray();
|
||||
boolean idle=args.length>1 && args[1].equals("idle"); int total=idle?40:10000; long started=System.nanoTime(); int exit=1;
|
||||
try {
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
PrintingLocalHost host=new PrintingLocalHost(new PrintingLocalSettings(jdbc,"sa","","tenant-"+tenant,0,"fixture-only-management-token-123456","operator",password));
|
||||
AtomicReference<Javalin> app=new AtomicReference<>();
|
||||
QApplicationLauncher launch=QApplicationLauncher.run(host,new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server->{host.configure(server,false);server.withJavalinConfigurationCustomizer(app::set);}));
|
||||
launches.add(launch);
|
||||
PrintingService service=launch.getQInstance().getQBits().values().stream().map(v->v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
|
||||
services.add(service);
|
||||
for(int lane=0;lane<4;lane++) {
|
||||
JSONObject enrolled=service.enrollForUser(service.createEnrollment("default","Synthetic lane").put("deviceName","Synthetic lane").put("agentVersion","qualification"),"operator");
|
||||
String device=enrolled.getString("deviceId");devices.add(device);
|
||||
service.inventory(device,new JSONArray().put(new JSONObject().put("printerId","synthetic").put("name","No physical printer").put("status","READY").put("formats",new JSONArray().put("ZPL"))));service.enablePrinter(device,"synthetic",true);
|
||||
inputs.put(new JSONObject().put("origin","http://127.0.0.1:"+app.get().port()+"/").put("username","operator").put("password",password).put("tenant","tenant-"+tenant).put("deviceId",device).put("deviceToken",enrolled.getString("deviceToken")));
|
||||
}
|
||||
}
|
||||
// Only synthetic credentials inside the already protected run directory.
|
||||
Files.writeString(dir.resolve("input.json"),new JSONObject().put("lanes",inputs).put("idle",idle).put("jobsPerLane",total/8).toString());
|
||||
await(dir.resolve("ready"),Duration.ofMinutes(2));
|
||||
byte[] payload=("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+" ".repeat(980)+"^XZ").getBytes(StandardCharsets.US_ASCII);
|
||||
String encoded=Base64.getEncoder().encodeToString(payload);
|
||||
long producing=System.nanoTime();
|
||||
if(idle) { for(int job=0;job<total;job++) { int lane=job%8; PrintingService service=services.get(lane/4); JSONObject request=new JSONObject().put("deviceId",devices.get(lane)).put("printerId","synthetic").put("title","Synthetic idle workload").put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","idle-"+job); service.submit(request); await(dir.resolve("completed-"+(job+1)),Duration.ofSeconds(30)); Thread.sleep(200); } } else try(var executor=Executors.newFixedThreadPool(8)) {
|
||||
List<Future<?>> futures=new ArrayList<>();
|
||||
for(int lane=0;lane<8;lane++) { final int index=lane;
|
||||
futures.add(executor.submit(()->{try{
|
||||
PrintingService service=services.get(index/4);
|
||||
for(int job=0;job<1250;job++) {
|
||||
JSONObject request=new JSONObject().put("deviceId",devices.get(index)).put("printerId","synthetic").put("title","Synthetic workload "+job).put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","lane-"+index+"-"+job);
|
||||
JSONObject submitted=service.submit(request);
|
||||
if(job%25==0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))throw new IllegalStateException("Replay duplicated job");
|
||||
}
|
||||
}catch(Exception e){throw new RuntimeException(e);}}));
|
||||
}
|
||||
for(var future:futures)future.get(15,TimeUnit.MINUTES);
|
||||
}
|
||||
double producerSeconds=(System.nanoTime()-producing)/1e9;
|
||||
Files.writeString(dir.resolve("producer-done"),"done");
|
||||
await(dir.resolve("done"),Duration.ofMinutes(15));
|
||||
JSONObject client=new JSONObject(Files.readString(dir.resolve("result.json")));
|
||||
if(!client.getBoolean("success"))throw new IllegalStateException("Client reported failure");
|
||||
JSONObject report=new JSONObject().put("success",true).put("jobs",total).put("receipts",total*3).put("idle",idle).put("producerSeconds",producerSeconds).put("elapsedSeconds",(System.nanoTime()-started)/1e9).put("realPrinterCalls",0);
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_event",total*3);
|
||||
require(query,"SELECT COUNT(*) FROM printing_document_orphan",0);
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-"+tenant+"'",total/2);
|
||||
require(query,"SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-"+tenant+"' AND device_id=''",(total/2L)*payload.length);
|
||||
}
|
||||
JSONArray cross=new JSONArray();for(int tenant=0;tenant<2;tenant++) {
|
||||
String foreign=services.get(1-tenant).jobsPage(1,null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
|
||||
try {services.get(tenant).getJob(foreign);throw new IllegalStateException("Foreign job visible");}catch(PrintingException denied){if(denied.getStatus()!=404)throw denied;}
|
||||
cross.put(true);
|
||||
}
|
||||
report.put("tenantIsolation",cross).put("retainedAccountingVerified",true).put("bytesPerDocument",payload.length);
|
||||
}
|
||||
Files.writeString(dir.resolve("server-result.json"),report.toString(2));exit=0;
|
||||
} catch(Throwable failure) {
|
||||
Files.writeString(dir.resolve("server-error.json"),new JSONObject().put("error",failure.getClass().getName()).put("cause",failure.getCause()==null?"":failure.getCause().getClass().getName()).toString());
|
||||
} finally {
|
||||
for(int i=launches.size()-1;i>=0;i--)launches.get(i).stop();
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()){query.execute("SHUTDOWN");}
|
||||
Files.writeString(dir.resolve("stopped.json"),new JSONObject().put("normalStop",true).put("exitCode",exit).toString());
|
||||
}
|
||||
System.exit(exit);
|
||||
}
|
||||
static void await(Path file,Duration limit)throws Exception {long deadline=System.nanoTime()+limit.toNanos();while(!Files.exists(file)){if(System.nanoTime()>deadline)throw new TimeoutException("Fixture deadline");Thread.sleep(100);}}
|
||||
static void require(Statement query,String sql,long expected)throws Exception {try(ResultSet rows=query.executeQuery(sql)){if(!rows.next()||rows.getLong(1)!=expected)throw new IllegalStateException("Synthetic count/accounting mismatch");}}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<Configuration status="ERROR">
|
||||
<Appenders><Console name="Console" target="SYSTEM_ERR"><PatternLayout pattern="%level %logger{1}: %message%n"/></Console></Appenders>
|
||||
<Loggers>
|
||||
<Logger name="software.amazon.awssdk" level="OFF"/>
|
||||
<Logger name="org.apache.http" level="OFF"/>
|
||||
<Root level="ERROR"><AppenderRef ref="Console"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
admin off
|
||||
skip_install_trust
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
https://auth.printing.internal {
|
||||
tls internal
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
}
|
||||
|
||||
https://qqq.printing.internal {
|
||||
tls internal
|
||||
reverse_proxy native-qqq:19082
|
||||
}
|
||||
|
||||
https://callback.printing.internal {
|
||||
tls internal
|
||||
reverse_proxy native-client:8088
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build
|
||||
# Same exact published production Core source as the normal development workload.
|
||||
ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
WORKDIR /fixture/dev/native-auth/client
|
||||
COPY dev/native-auth/client .
|
||||
COPY dev/client/WorkloadDiscovery.cs /fixture/dev/client/WorkloadDiscovery.cs
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet /out/NativeAuth.dll --check-discovery
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
WORKDIR /app
|
||||
COPY --from=build /out .
|
||||
COPY dev/native-auth/start-client.sh /app/start-client.sh
|
||||
RUN chmod 0555 /app/start-client.sh
|
||||
ENV DOTNET_EnableDiagnostics=0
|
||||
ENTRYPOINT ["/app/start-client.sh"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build
|
||||
# DEVELOPMENT ONLY: exact public candidate, never installed into a host Maven cache.
|
||||
ADD --checksum=sha256:d99bafdf5520d0dc039b598787c003f23505cec3a652eb8ded496a24c6ed6060 https://github.com/QRun-IO/qqq/archive/ecf0f550a0357cec6387c2b54efca128a405a629.tar.gz /tmp/core.tar.gz
|
||||
RUN mkdir /candidate && tar -xzf /tmp/core.tar.gz -C /candidate --strip-components=1 && rm /tmp/core.tar.gz
|
||||
WORKDIR /candidate
|
||||
RUN mvn -B -Dmaven.repo.local=/candidate-maven -Drevision=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true -pl qqq-bom,qqq-backend-core,qqq-backend-module-rdbms,qqq-backend-module-filesystem,qqq-middleware-javalin -am install
|
||||
WORKDIR /printing
|
||||
COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Dmaven.repo.local=/candidate-maven -Dqqq.version=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/native-auth/java /fixture-src
|
||||
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
WORKDIR /app
|
||||
COPY --from=build /printing/target/classes classes
|
||||
COPY --from=build /printing/target/runtime lib
|
||||
COPY --from=build /fixture-classes fixture
|
||||
COPY dev/log4j2.xml /app/log4j2.xml
|
||||
COPY dev/native-auth/start-server.sh /app/start-server.sh
|
||||
RUN chmod 0555 /app/start-server.sh
|
||||
ENV NATIVE_CORE_SOURCE=ecf0f550a0357cec6387c2b54efca128a405a629
|
||||
ENTRYPOINT ["/app/start-server.sh"]
|
||||
@@ -0,0 +1,96 @@
|
||||
# Experimental native Authentik qualification
|
||||
|
||||
This optional profile tests an **unreleased upstream QQQ candidate**. It does
|
||||
not replace the default released RC.20 TABLE/MinIO development environment,
|
||||
install a connector, or qualify the Windows browser callback UI.
|
||||
|
||||
From the repository root, with Docker and PowerShell 7.4 or later:
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth
|
||||
```
|
||||
|
||||
The command creates a fresh owned project, builds the pinned sources, runs the
|
||||
test, and removes its containers, network and synthetic database volumes in
|
||||
`finally`. Protected input and sanitized result files remain outside the
|
||||
checkout. Do not print or commit the private input or Compose environment.
|
||||
An explicit fresh `-StateDirectory` preserves the same cleanup behavior.
|
||||
Persistent `Start` and conversion of an existing standard environment are
|
||||
refused for this profile.
|
||||
|
||||
The test uses actual Authentik login stages with generated synthetic accounts,
|
||||
the standard QQQ OAuth2 code/session/refresh handlers, and the published .NET
|
||||
Core PKCE/session client. The provider issues the authorization codes. The
|
||||
client receives a real HTTPS callback and stores only the opaque QQQ session
|
||||
in process; provider refresh tokens remain on the QQQ server.
|
||||
|
||||
The required checks are:
|
||||
|
||||
- Ordinary TLS rejects the fixture before its private CA is installed.
|
||||
- Actual API discovery advertises the typed native session grant.
|
||||
- Authorized real provider login resolves a server-derived native identity.
|
||||
- A wrong-Origin cookie request and an authenticated subject without the
|
||||
printing permission are rejected.
|
||||
- A logged-out QQQ session cannot be replayed.
|
||||
- After the 20-second provider access grant expires, standard QQQ renewal
|
||||
obtains a new provider grant and preserves the principal and tenant.
|
||||
- Revoking the synthetic subject's provider refresh grants prevents renewal
|
||||
after the next access-grant expiry.
|
||||
|
||||
Success is recorded only when every check passes. A failure report contains
|
||||
the stage and exception type, without callback URLs, credentials or response
|
||||
bodies. This short renewal test does not prove months of unattended operation.
|
||||
|
||||
Printing discovery accepts stable semantic versions in major 2, including
|
||||
2.0.0 and 2.1.0, through the same production Core validation and shared major
|
||||
gate as the default workload. The experimental upstream API must still be
|
||||
exactly 1.9.0 with `nativeSessionLogin`; typed metadata, PKCE, TLS, callback and
|
||||
permission checks are unchanged. The native-client image build runs 12 fake-HTTP
|
||||
compatibility checks before any provider or fixture input is used.
|
||||
|
||||
## Isolation and source pins
|
||||
|
||||
The upstream source is
|
||||
[`ecf0f550a0357cec6387c2b54efca128a405a629`](https://github.com/QRun-IO/qqq/commit/ecf0f550a0357cec6387c2b54efca128a405a629).
|
||||
Its archive checksum is pinned in `Dockerfile.server`. Maven installs the
|
||||
development build only into `/candidate-maven` inside the image; no host Maven
|
||||
cache or installed runtime is mounted or changed. Printing source is compiled
|
||||
against that candidate solely for this test.
|
||||
|
||||
The production .NET Core client is pinned to published source
|
||||
[`346fc285198908c47a2ee9db7b9adb53d5e88c3c`](https://github.com/QRun-IO/qqq-print-agent/commit/346fc285198908c47a2ee9db7b9adb53d5e88c3c)
|
||||
with a checked archive hash. Authentik, PostgreSQL, Caddy, Java and .NET images
|
||||
are pinned by digest in the Compose files and Dockerfiles.
|
||||
|
||||
Caddy issues the three fixture HTTPS hostnames using its private local CA.
|
||||
`skip_install_trust` prevents automatic trust installation. Only the public
|
||||
root certificate is copied to the Java and .NET containers' trust stores;
|
||||
ordinary chain and hostname validation remain enabled. CA private keys stay
|
||||
in the TLS container's owned volume. No Windows or browser trust store changes
|
||||
occur. See [Caddy's local HTTPS documentation](https://caddyserver.com/docs/automatic-https#local-https).
|
||||
|
||||
The HTTPS callback receiver is internal to the Compose network. It is separate
|
||||
from the installed connector's private URI callback. No callback host port is
|
||||
published. The profile mounts public Caddy configuration and the two specific
|
||||
private input/result files, rather than a host home or credential directory.
|
||||
|
||||
The QQQ fixture uses fresh standard session, redirect-state and encrypted
|
||||
refresh-token tables. Its customizer maps the verified provider's synthetic
|
||||
`printing_admin` group to the existing native permission. It creates no device
|
||||
pairing, job, print operation or fabricated user session. Released RC.20 lacks
|
||||
the native-session grant; this experiment must stay opt-in until an appropriate
|
||||
immutable upstream tuple is published and separately adopted.
|
||||
|
||||
## Recorded local qualification
|
||||
|
||||
On 2026-11-01 the full disposable profile passed all eight checks and exited zero.
|
||||
Independent inventory confirmed zero owned containers, volumes and networks after
|
||||
cleanup. This is evidence for the pinned experimental tuple above, not adoption
|
||||
into the default released runtime or installed Windows UI acceptance.
|
||||
|
||||
A fresh run against server source `3592dec` (printing API 2.1.0) and the reviewed
|
||||
probe compatibility correction also passed all eight checks on 2026-11-01.
|
||||
All source files matched the pre-run hash manifest through completion, and
|
||||
independent cleanup again found no owned containers, volumes or networks.
|
||||
The native image's 12 discovery checks and default workload's nine checks pass.
|
||||
The unreleased upstream and installed-browser limitations above still apply.
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Receives the actual HTTPS-proxied callback only inside this test network.</summary>
|
||||
internal sealed class CallbackReceiver : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener = new();
|
||||
private readonly Channel<Uri> _callbacks = Channel.CreateBounded<Uri>(1);
|
||||
private readonly Uri _callback;
|
||||
|
||||
internal CallbackReceiver(Uri callback)
|
||||
{
|
||||
_callback = callback;
|
||||
_listener.Prefixes.Add("http://+:8088/");
|
||||
_listener.Start();
|
||||
_ = ReceiveAsync();
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while(_listener.IsListening)
|
||||
{
|
||||
HttpListenerContext context = await _listener.GetContextAsync();
|
||||
Uri? request = context.Request.Url;
|
||||
bool valid = request is not null && request.AbsolutePath == _callback.AbsolutePath
|
||||
&& context.Request.Headers["Host"] == _callback.Authority
|
||||
&& context.Request.Headers["X-Forwarded-Proto"] == "https"
|
||||
&& context.Request.HttpMethod == "GET" && request.Query.Length is > 0 and < 16384;
|
||||
if(valid)
|
||||
{
|
||||
var actual = new UriBuilder(_callback) { Query = request!.Query.TrimStart('?') };
|
||||
valid = _callbacks.Writer.TryWrite(actual.Uri);
|
||||
}
|
||||
context.Response.StatusCode = valid ? 200 : 400;
|
||||
context.Response.ContentLength64 = 0;
|
||||
context.Response.Close();
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is HttpListenerException or ObjectDisposedException)
|
||||
{
|
||||
_callbacks.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
internal ValueTask<Uri> ReadAsync(CancellationToken token) => _callbacks.Reader.ReadAsync(token);
|
||||
public void Dispose() => _listener.Close();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Small bounded fixture transport; no redirects, proxy or response-body diagnostics.</summary>
|
||||
internal sealed class FixtureHttp : IDisposable
|
||||
{
|
||||
internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location);
|
||||
internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery);
|
||||
internal static List<TraceEntry> Trace { get; } = [];
|
||||
internal static string? LastRequestKind { get; private set; }
|
||||
internal static int? LastStatus { get; private set; }
|
||||
private readonly HttpClient _client;
|
||||
private readonly CookieContainer _cookies = new();
|
||||
private readonly Uri _origin;
|
||||
private readonly string? _adminToken;
|
||||
|
||||
internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null)
|
||||
{
|
||||
_origin = origin;
|
||||
_adminToken = adminToken;
|
||||
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = cookies, CookieContainer = _cookies })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
MaxResponseContentBufferSize = 524288,
|
||||
};
|
||||
}
|
||||
|
||||
internal async Task<Reply> SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken)
|
||||
{
|
||||
if(uri.Scheme != "https" || uri.GetLeftPart(UriPartial.Authority) != _origin.GetLeftPart(UriPartial.Authority))
|
||||
{
|
||||
throw new InvalidOperationException("A fixture request left its configured HTTPS origin.");
|
||||
}
|
||||
using var request = new HttpRequestMessage(new HttpMethod(method), uri);
|
||||
// Fixed classifications only: never retain a query, callback, identifier or body.
|
||||
LastRequestKind = Classify(uri, method);
|
||||
LastStatus = null;
|
||||
if(_adminToken is not null)
|
||||
{
|
||||
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal)) { throw new InvalidOperationException("Administrative credential scope refused."); }
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||
}
|
||||
if(body is not null)
|
||||
{
|
||||
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority));
|
||||
request.Headers.Referrer = _origin;
|
||||
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "authentik_csrf");
|
||||
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "csrftoken");
|
||||
if(csrf is not null) { request.Headers.Add("X-authentik-CSRF", csrf.Value); }
|
||||
}
|
||||
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
|
||||
LastStatus = (int)response.StatusCode;
|
||||
Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location);
|
||||
if(Trace.Count == 32) { Trace.RemoveAt(0); }
|
||||
Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0,
|
||||
location is null ? null : Classify(location, "GET"), location?.Query.Length > 0));
|
||||
string content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
JsonObject? json = response.Content.Headers.ContentType?.MediaType == "application/json" && content.Length != 0
|
||||
? JsonNode.Parse(content)?.AsObject() : null;
|
||||
return new Reply(response.StatusCode, json, response.Headers.Location);
|
||||
}
|
||||
|
||||
internal Task<Reply> SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken)
|
||||
=> SendAsync(method, new Uri(_origin, path), body, cancellationToken);
|
||||
|
||||
private static string Classify(Uri uri, string method) => uri.Host == "callback.printing.internal" ? "callback"
|
||||
: uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization"
|
||||
: uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page"
|
||||
: uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method
|
||||
: "fixture-api";
|
||||
|
||||
internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected)
|
||||
{
|
||||
if(reply.Status != expected || reply.Json is null) { throw new InvalidOperationException("Unexpected fixture API status or document shape."); }
|
||||
return reply.Json;
|
||||
}
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Requires the pinned experimental native grant before provider authentication.</summary>
|
||||
internal static class NativeAuthDiscovery
|
||||
{
|
||||
/// <summary>Checks actual production discovery without sending credentials.</summary>
|
||||
internal static async Task DiscoverAsync(QqqSessionClient client, CancellationToken token)
|
||||
{
|
||||
QqqApiDiscovery discovery = await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
|
||||
if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The actual server did not advertise the pinned native-session API.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Exercises the real native probe gate against bounded, credential-free HTTP responses.</summary>
|
||||
internal static class NativeAuthDiscoveryChecks
|
||||
{
|
||||
/// <summary>Accepts compatible printing releases while preserving the exact upstream grant requirement.</summary>
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string PrintingVersion, string ApiVersion, bool NativeGrant, bool Accepted)[] cases =
|
||||
[
|
||||
("2.0.0", "1.9.0", true, true),
|
||||
("2.1.0", "1.9.0", true, true),
|
||||
("2.9.17", "1.9.0", true, true),
|
||||
("1.9.0", "1.9.0", true, false),
|
||||
("3.0.0", "1.9.0", true, false),
|
||||
("2.1", "1.9.0", true, false),
|
||||
("2.01.0", "1.9.0", true, false),
|
||||
("2.1.0-rc.1", "1.9.0", true, false),
|
||||
("not-a-version", "1.9.0", true, false),
|
||||
("2.1.0", "1.8.0", true, false),
|
||||
("2.1.0", "1.10.0", true, false),
|
||||
("2.1.0", "1.9.0", false, false),
|
||||
];
|
||||
foreach((string printingVersion, string apiVersion, bool nativeGrant, bool expected) in cases)
|
||||
{
|
||||
using var handler = new DiscoveryHandler(printingVersion, apiVersion, nativeGrant);
|
||||
using var client = new QqqSessionClient(new Uri("https://printing.example.invalid"), handler);
|
||||
bool accepted = false;
|
||||
try
|
||||
{
|
||||
await NativeAuthDiscovery.DiscoverAsync(client, CancellationToken.None);
|
||||
accepted = true;
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
|
||||
{
|
||||
}
|
||||
catch(InvalidDataException)
|
||||
{
|
||||
}
|
||||
catch(InvalidOperationException)
|
||||
{
|
||||
}
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
throw new InvalidDataException($"Native discovery compatibility case {printingVersion}/{apiVersion}/grant={nativeGrant} failed.");
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"Native discovery compatibility checks passed: {cases.Length}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Supplies discovery only and fails if the probe attempts authenticated traffic.</summary>
|
||||
private sealed class DiscoveryHandler(string printingVersion, string apiVersion, bool nativeGrant) : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Ensures rejected metadata still traversed the public HTTP discovery boundary.</summary>
|
||||
internal int Requests { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses any credential or request outside the public discovery boundary.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsoluteUri != "https://printing.example.invalid/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization")
|
||||
|| request.Headers.Contains("X-QQQ-Device-Id") || request.Headers.Contains("X-QQQ-Device-Token"))
|
||||
{
|
||||
throw new InvalidOperationException("Native discovery must precede authentication and device proof.");
|
||||
}
|
||||
Requests++;
|
||||
string[] features = nativeGrant
|
||||
? ["version", "metaData", "processes", "processCancel", "extensions", "widgets", "nativeSessionLogin"]
|
||||
: ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
string json = JsonSerializer.Serialize(new
|
||||
{
|
||||
apiVersion,
|
||||
features,
|
||||
clientChannels = new[] { "api" },
|
||||
extensions = new[] { new { name = "printing", version = printingVersion, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const string CoreSource = "ecf0f550a0357cec6387c2b54efca128a405a629";
|
||||
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
|
||||
private const string InputPath = "/work/input.json";
|
||||
private const string ResultPath = "/work/result.json";
|
||||
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
|
||||
private static readonly List<string> Checks = [];
|
||||
private static string _stage = "input";
|
||||
private static bool? _authorizationRequestMatches;
|
||||
|
||||
public static async Task<int> Main(string[] arguments)
|
||||
{
|
||||
if(arguments is ["--check-discovery"])
|
||||
{
|
||||
await NativeAuthDiscoveryChecks.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
JsonObject? input = null;
|
||||
try
|
||||
{
|
||||
if(arguments.Length != 1 || arguments[0] is not ("assert-untrusted" or "test"))
|
||||
{ throw new InvalidOperationException("An explicit fixture action is required."); }
|
||||
if(new FileInfo(InputPath).Length is <= 0 or > 65536)
|
||||
{ throw new InvalidOperationException("Fixture input size is invalid."); }
|
||||
input = JsonNode.Parse(await File.ReadAllTextAsync(InputPath))!.AsObject();
|
||||
ValidateInput(input);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
if(arguments[0] == "assert-untrusted")
|
||||
{
|
||||
_stage = "untrusted-tls";
|
||||
await AssertUntrustedAsync(new Uri(ProviderSetup.Text(input, "providerOrigin")), deadline.Token);
|
||||
await File.WriteAllTextAsync(TlsMarker, "passed", deadline.Token);
|
||||
return 0;
|
||||
}
|
||||
if(!File.Exists(TlsMarker)) { throw new InvalidOperationException("The ordinary TLS negative control did not run."); }
|
||||
Checks.Add("untrusted-tls-refused");
|
||||
await QualifyAsync(input, deadline.Token);
|
||||
await WriteResultAsync(input, "PASS", null, null);
|
||||
Console.WriteLine("Experimental native Authentik qualification passed; development-only unpublished core.");
|
||||
return 0;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
// Exception messages, response bodies, callback URLs and credentials are deliberately withheld.
|
||||
await WriteResultAsync(input, "FAILED", exception.GetType().Name,
|
||||
exception is QqqSessionException sessionException ? sessionException.Failure.ToString() : null);
|
||||
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + _stage + " (" + exception.GetType().Name + ").");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInput(JsonObject input)
|
||||
{
|
||||
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant)
|
||||
|| ProviderSetup.Text(input, "coreSource") != CoreSource || ProviderSetup.Text(input, "clientSource") != ClientSource
|
||||
|| ProviderSetup.Text(input, "providerOrigin") != "https://auth.printing.internal"
|
||||
|| ProviderSetup.Text(input, "qqqOrigin") != "https://qqq.printing.internal"
|
||||
|| ProviderSetup.Text(input, "callback") != "https://callback.printing.internal/oauth/callback"
|
||||
|| ProviderSetup.Text(input, "issuer") != "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/"
|
||||
|| ProviderSetup.Text(input, "clientId") != ProviderSetup.Text(input, "project"))
|
||||
{ throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract."); }
|
||||
}
|
||||
|
||||
private static QqqSessionClient Session(Uri origin) => new(origin,
|
||||
new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false });
|
||||
|
||||
private static async Task AssertUntrustedAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var transport = new FixtureHttp(origin);
|
||||
try { await transport.SendAsync("GET", "/-/health/live/", null, token); }
|
||||
catch(HttpRequestException exception)
|
||||
{
|
||||
for(Exception? cause = exception; cause is not null; cause = cause.InnerException)
|
||||
{
|
||||
if(cause is AuthenticationException) { return; }
|
||||
}
|
||||
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.");
|
||||
}
|
||||
throw new InvalidOperationException("The test unexpectedly trusted its private CA before scoped installation.");
|
||||
}
|
||||
|
||||
private static async Task QualifyAsync(JsonObject input, CancellationToken token)
|
||||
{
|
||||
var origin = new Uri(ProviderSetup.Text(input, "qqqOrigin"));
|
||||
var providerOrigin = new Uri(ProviderSetup.Text(input, "providerOrigin"));
|
||||
var callback = new Uri(ProviderSetup.Text(input, "callback"));
|
||||
_stage = "readiness";
|
||||
await WaitForQqqAsync(origin, token);
|
||||
using var provider = new ProviderSetup(input);
|
||||
_stage = "provider-readiness";
|
||||
await provider.WaitReadyAsync(token);
|
||||
_stage = "provider-configuration";
|
||||
await provider.ConfigureAsync(token);
|
||||
using var receiver = new CallbackReceiver(callback);
|
||||
using var first = Session(origin);
|
||||
_stage = "typed-native-discovery";
|
||||
await NativeAuthDiscovery.DiscoverAsync(first, token);
|
||||
QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token);
|
||||
if(metadata.Values?.ClientId != ProviderSetup.Text(input, "clientId"))
|
||||
{ throw new InvalidOperationException("Native metadata did not retain the configured client identifier."); }
|
||||
Checks.Add("typed-native-grant");
|
||||
|
||||
_stage = "real-provider-login";
|
||||
await LoginAsync(first, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
_stage = "authorized-native-identity";
|
||||
QqqSessionBinding identity = await first.GetSessionIdentityAsync(token);
|
||||
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || identity.TenantId != "printing-native-dev" || identity.DeviceId is not null)
|
||||
{ throw new InvalidOperationException("The native subject did not receive the intended server-derived identity."); }
|
||||
Checks.Add("real-provider-login");
|
||||
_stage = "wrong-origin-cookie";
|
||||
await AssertWrongOriginAsync(origin, first.ExportCredential(), token);
|
||||
await first.GetSessionIdentityAsync(token);
|
||||
Checks.Add("wrong-origin-cookie-denied");
|
||||
|
||||
_stage = "permission-denied-user";
|
||||
using(var denied = Session(origin))
|
||||
{
|
||||
await LoginAsync(denied, receiver, input, providerOrigin, callback, authorized: false, token);
|
||||
await ExpectFailureAsync(() => denied.GetSessionIdentityAsync(token), QqqSessionException.FailureKind.PermissionDenied);
|
||||
await denied.SignOutAsync(token);
|
||||
}
|
||||
Checks.Add("permission-denied-user");
|
||||
|
||||
_stage = "logout-replay";
|
||||
QqqSessionCredential retired = first.ExportCredential();
|
||||
await first.SignOutAsync(token);
|
||||
using(var replay = Session(origin))
|
||||
{ await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired); }
|
||||
Checks.Add("logout-replay-denied");
|
||||
|
||||
_stage = "fresh-login-for-renewal";
|
||||
using var renewable = Session(origin);
|
||||
await LoginAsync(renewable, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
QqqSessionBinding before = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> previousGrants = await provider.AccessGrantIdsAsync(token);
|
||||
_stage = "server-refresh-after-expiry";
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await renewable.ResumeSessionAsync(token);
|
||||
QqqSessionBinding after = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> refreshedGrants = await provider.AccessGrantIdsAsync(token);
|
||||
if(before != after || !refreshedGrants.Except(previousGrants, StringComparer.Ordinal).Any())
|
||||
{ throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant."); }
|
||||
Checks.Add("server-refresh-after-expiry");
|
||||
|
||||
_stage = "provider-revocation-after-expiry";
|
||||
await provider.RevokeRefreshGrantsAsync(token);
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await ExpectFailureAsync(() => renewable.ResumeSessionAsync(token), QqqSessionException.FailureKind.AuthenticationRequired);
|
||||
Checks.Add("provider-revocation-denied");
|
||||
_stage = "complete";
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(QqqSessionClient client, CallbackReceiver receiver, JsonObject input,
|
||||
Uri providerOrigin, Uri callback, bool authorized, CancellationToken token)
|
||||
{
|
||||
string role = authorized ? "authorized" : "denied";
|
||||
_stage = role + "-begin-pkce";
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(callback, token);
|
||||
_stage = role + "-generated-authorization-request";
|
||||
Dictionary<string, string> parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
|
||||
.Select(entry => entry.Split('=', 2)).ToDictionary(pair => Uri.UnescapeDataString(pair[0]), pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
|
||||
_authorizationRequestMatches = attempt.AuthorizationUri.AbsolutePath == "/application/o/authorize/"
|
||||
&& parameters.GetValueOrDefault("client_id") == ProviderSetup.Text(input, "clientId")
|
||||
&& parameters.GetValueOrDefault("redirect_uri") == callback.AbsoluteUri
|
||||
&& parameters.GetValueOrDefault("response_type") == "code"
|
||||
&& parameters.GetValueOrDefault("code_challenge_method") == "S256"
|
||||
&& parameters.GetValueOrDefault("state")?.Length == 43;
|
||||
if(_authorizationRequestMatches != true)
|
||||
{ throw new InvalidOperationException("The generated authorization request differs from the exact provider registration."); }
|
||||
_stage = role + "-provider-flow";
|
||||
await ProviderFlow.AuthenticateAsync(attempt.AuthorizationUri, providerOrigin, callback,
|
||||
ProviderSetup.Text(input, role + "Username"), ProviderSetup.Text(input, role + "Password"), token);
|
||||
_stage = role + "-callback";
|
||||
Uri actualCallback = await receiver.ReadAsync(token);
|
||||
_stage = role + "-exchange";
|
||||
await client.CompleteBrowserLoginAsync(attempt, actualCallback, token);
|
||||
}
|
||||
|
||||
private static async Task ExpectFailureAsync(Func<Task> operation, QqqSessionException.FailureKind expected)
|
||||
{
|
||||
try { await operation(); }
|
||||
catch(QqqSessionException exception) when(exception.Failure == expected) { return; }
|
||||
throw new InvalidOperationException("The expected native authentication or permission denial did not occur.");
|
||||
}
|
||||
|
||||
private static async Task AssertWrongOriginAsync(Uri origin, QqqSessionCredential credential, CancellationToken token)
|
||||
{
|
||||
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false })
|
||||
{ Timeout = TimeSpan.FromSeconds(15), MaxResponseContentBufferSize = 65536 };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(origin, "/qqq/v1/qbit/printing/session"));
|
||||
request.Headers.Add("X-QQQ-Client", QqqSessionClient.ClientChannel);
|
||||
request.Headers.Add("Cookie", "sessionUUID=" + credential.SessionToken);
|
||||
request.Headers.Add("Origin", "https://wrong-origin.invalid");
|
||||
using HttpResponseMessage response = await http.SendAsync(request, token);
|
||||
if(response.StatusCode != HttpStatusCode.Forbidden)
|
||||
{ throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request."); }
|
||||
}
|
||||
|
||||
private static async Task WaitForQqqAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var http = new FixtureHttp(origin);
|
||||
for(int attempt = 0; attempt < 45; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
FixtureHttp.Reply response = await http.SendAsync("GET", "/qqq/v1/version", null, token);
|
||||
if(response.Status == HttpStatusCode.OK && response.Json is not null) { return; }
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned native host did not become ready.");
|
||||
}
|
||||
|
||||
private static Task WriteResultAsync(JsonObject? input, string status, string? failureType, string? sessionFailure)
|
||||
=> File.WriteAllTextAsync(ResultPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
status, scope = "DEVELOPMENT_ONLY_UNRELEASED", coreSource = CoreSource, clientSource = ClientSource,
|
||||
project = input?["project"]?.ToString(), observedUtc = DateTimeOffset.UtcNow, stage = _stage, failureType,
|
||||
sessionFailure, lastRequestKind = FixtureHttp.LastRequestKind, lastHttpStatus = FixtureHttp.LastStatus,
|
||||
lastProviderComponent = ProviderFlow.LastComponent,
|
||||
authorizationRequestMatches = _authorizationRequestMatches,
|
||||
requestClassifications = FixtureHttp.Trace,
|
||||
providerLookupKind = ProviderSetup.LastLookupKind, providerLookupCount = ProviderSetup.LastLookupCount,
|
||||
providerReadinessCounts = ProviderSetup.ReadinessCounts,
|
||||
checks = Checks, installedUiAcceptance = false, providerRefreshTokensInClient = false,
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Uses Authentik's real synthetic-user login stages; never fabricates a session or code.</summary>
|
||||
internal static class ProviderFlow
|
||||
{
|
||||
internal static string? LastComponent { get; private set; }
|
||||
internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token)
|
||||
{
|
||||
using var browser = new FixtureHttp(origin, cookies: true);
|
||||
using var callbackHttp = new FixtureHttp(new Uri(callback.GetLeftPart(UriPartial.Authority)));
|
||||
Uri next = authorization;
|
||||
for(int hop = 0; hop < 24; hop++)
|
||||
{
|
||||
if(next.GetLeftPart(UriPartial.Authority) == callback.GetLeftPart(UriPartial.Authority))
|
||||
{
|
||||
if(next.AbsolutePath != callback.AbsolutePath) { throw new InvalidOperationException("Unexpected callback path."); }
|
||||
FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token);
|
||||
if(result.Status != HttpStatusCode.OK) { throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect."); }
|
||||
return;
|
||||
}
|
||||
if(next.GetLeftPart(UriPartial.Authority) != origin.GetLeftPart(UriPartial.Authority))
|
||||
{ throw new InvalidOperationException("A provider redirect left the fixture."); }
|
||||
FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token);
|
||||
if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect)
|
||||
{
|
||||
next = new Uri(next, page.Location ?? throw new InvalidOperationException("Missing provider redirect."));
|
||||
continue;
|
||||
}
|
||||
if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal))
|
||||
{ throw new InvalidOperationException("Unexpected provider login navigation."); }
|
||||
string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/');
|
||||
if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))
|
||||
{ throw new InvalidOperationException("Unexpected provider flow identifier."); }
|
||||
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
|
||||
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
|
||||
bool redirected = false;
|
||||
for(int stage = 0; stage < 10; stage++)
|
||||
{
|
||||
string component = ProviderSetup.Text(challenge, "component");
|
||||
LastComponent = component switch
|
||||
{
|
||||
"ak-stage-identification" => "identification",
|
||||
"ak-stage-password" => "password",
|
||||
"ak-stage-consent" => "consent",
|
||||
"xak-flow-redirect" => "redirect",
|
||||
"ak-stage-access-denied" => "access-denied",
|
||||
"ak-stage-flow-error" => "flow-error",
|
||||
_ => "unsupported",
|
||||
};
|
||||
if(component == "xak-flow-redirect")
|
||||
{
|
||||
next = new Uri(origin, ProviderSetup.Text(challenge, "to"));
|
||||
redirected = true;
|
||||
break;
|
||||
}
|
||||
JsonObject response = component switch
|
||||
{
|
||||
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
|
||||
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
|
||||
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
|
||||
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
|
||||
};
|
||||
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
|
||||
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
|
||||
{ throw new InvalidOperationException("The real provider rejected the synthetic login response."); }
|
||||
}
|
||||
if(!redirected) { throw new InvalidOperationException("Provider stage bound exceeded."); }
|
||||
}
|
||||
throw new InvalidOperationException("Provider redirect bound exceeded.");
|
||||
}
|
||||
|
||||
private static async Task<JsonObject> ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token)
|
||||
{
|
||||
// Authentik uses POST/redirect/GET when a real stage accepts its answer.
|
||||
// Follow only that same HTTPS executor; other navigation uses typed flow redirects.
|
||||
for(int redirect = 0; redirect < 8; redirect++)
|
||||
{
|
||||
if(response.Status == HttpStatusCode.OK) { return FixtureHttp.RequireJson(response, HttpStatusCode.OK); }
|
||||
if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null)
|
||||
{ throw new InvalidOperationException("Unexpected provider stage response."); }
|
||||
var location = new Uri(executor, response.Location);
|
||||
if(location.GetLeftPart(UriPartial.Authority) != executor.GetLeftPart(UriPartial.Authority)
|
||||
|| location.AbsolutePath != executor.AbsolutePath)
|
||||
{ throw new InvalidOperationException("A stage redirect left its exact HTTPS executor."); }
|
||||
response = await browser.SendAsync("GET", location, null, token);
|
||||
}
|
||||
throw new InvalidOperationException("Provider stage redirect bound exceeded.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
/// <summary>Configures only the fresh project's synthetic provider and subjects.</summary>
|
||||
internal sealed class ProviderSetup : IDisposable
|
||||
{
|
||||
internal static string? LastLookupKind { get; private set; }
|
||||
internal static int? LastLookupCount { get; private set; }
|
||||
internal static Dictionary<string, int> ReadinessCounts { get; } = new(StringComparer.Ordinal);
|
||||
private readonly JsonObject _input;
|
||||
private readonly FixtureHttp _admin;
|
||||
private string _provider = "";
|
||||
private string _authorizedUser = "";
|
||||
|
||||
internal ProviderSetup(JsonObject input)
|
||||
{
|
||||
_input = input;
|
||||
_admin = new FixtureHttp(new Uri(Text(input, "providerOrigin")), adminToken: Text(input, "adminToken"));
|
||||
}
|
||||
|
||||
internal static string Text(JsonObject document, string key) => document[key]?.ToString() ?? throw new InvalidOperationException("Required fixture field is absent.");
|
||||
|
||||
private async Task<JsonObject> ExactAsync(string path, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", path, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
LastLookupKind = LookupKind(path);
|
||||
LastLookupCount = records.Count;
|
||||
if(records.Count != 1) { throw new InvalidOperationException("Required provider object is absent or ambiguous."); }
|
||||
return records[0]!.AsObject();
|
||||
}
|
||||
|
||||
private async Task<JsonObject> CreateAsync(string path, JsonObject body, CancellationToken token)
|
||||
=> FixtureHttp.RequireJson(await _admin.SendAsync("POST", path, body, token), HttpStatusCode.Created);
|
||||
|
||||
internal async Task WaitReadyAsync(CancellationToken token)
|
||||
{
|
||||
string[] paths = ["/api/v3/flows/instances/?slug=default-authentication-flow",
|
||||
"/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent",
|
||||
"/api/v3/flows/instances/?slug=default-provider-invalidation-flow",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=openid",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=profile",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=email",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=offline_access"];
|
||||
for(int attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
bool ready = true;
|
||||
try
|
||||
{
|
||||
foreach(string path in paths)
|
||||
{
|
||||
FixtureHttp.Reply reply = await _admin.SendAsync("GET", path, null, token);
|
||||
JsonArray? records = reply.Status == HttpStatusCode.OK ? reply.Json?["results"] as JsonArray : null;
|
||||
string kind = LookupKind(path);
|
||||
ReadinessCounts[kind] = records?.Count ?? -1;
|
||||
if(records?.Count > 1) { throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous."); }
|
||||
bool present = records is { Count: 1 };
|
||||
if(kind == "authentication-flow")
|
||||
{ present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 }; }
|
||||
ready &= present;
|
||||
}
|
||||
if(ready) { return; }
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned provider bootstrap did not become ready.");
|
||||
}
|
||||
|
||||
private static string LookupKind(string path) => path switch
|
||||
{
|
||||
"/api/v3/flows/instances/?slug=default-authentication-flow" => "authentication-flow",
|
||||
"/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent" => "authorization-flow",
|
||||
"/api/v3/flows/instances/?slug=default-provider-invalidation-flow" => "invalidation-flow",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=openid" => "scope-openid",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=profile" => "scope-profile",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=email" => "scope-email",
|
||||
"/api/v3/propertymappings/provider/scope/?scope_name=offline_access" => "scope-offline-access",
|
||||
_ => "other",
|
||||
};
|
||||
|
||||
internal async Task ConfigureAsync(CancellationToken token)
|
||||
{
|
||||
JsonObject authentication = await ExactAsync("/api/v3/flows/instances/?slug=default-authentication-flow", token);
|
||||
JsonObject authorization = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent", token);
|
||||
JsonObject invalidation = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-invalidation-flow", token);
|
||||
var mappings = new JsonArray();
|
||||
foreach(string scope in new[] { "openid", "profile", "email", "offline_access" })
|
||||
{
|
||||
mappings.Add((await ExactAsync("/api/v3/propertymappings/provider/scope/?scope_name=" + scope, token))["pk"]!.DeepClone());
|
||||
}
|
||||
JsonObject groupsMapping = await CreateAsync("/api/v3/propertymappings/provider/scope/", new JsonObject
|
||||
{
|
||||
["name"] = Text(_input, "project") + "-groups",
|
||||
["scope_name"] = "profile",
|
||||
["expression"] = "return {\"groups\": [group.name for group in user.ak_groups.all()]}",
|
||||
}, token);
|
||||
mappings.Add(groupsMapping["pk"]!.DeepClone());
|
||||
JsonObject group = await CreateAsync("/api/v3/core/groups/", new JsonObject { ["name"] = "printing_admin", ["is_superuser"] = false }, token);
|
||||
JsonObject user = await CreateUserAsync("authorizedUsername", "authorizedPassword", new JsonArray(group["pk"]!.DeepClone()), token);
|
||||
_authorizedUser = Text(user, "pk");
|
||||
await CreateUserAsync("deniedUsername", "deniedPassword", new JsonArray(), token);
|
||||
JsonObject provider = await CreateAsync("/api/v3/providers/oauth2/", new JsonObject
|
||||
{
|
||||
["name"] = Text(_input, "project"),
|
||||
["authentication_flow"] = authentication["pk"]!.DeepClone(),
|
||||
["authorization_flow"] = authorization["pk"]!.DeepClone(),
|
||||
["invalidation_flow"] = invalidation["pk"]!.DeepClone(),
|
||||
["client_type"] = "confidential",
|
||||
["grant_types"] = new JsonArray("authorization_code", "refresh_token"),
|
||||
["client_id"] = Text(_input, "clientId"),
|
||||
["client_secret"] = Text(_input, "clientSecret"),
|
||||
["access_code_validity"] = "minutes=1",
|
||||
["access_token_validity"] = "seconds=20",
|
||||
["refresh_token_validity"] = "minutes=5",
|
||||
["refresh_token_threshold"] = "minutes=4",
|
||||
["redirect_uris"] = new JsonArray(new JsonObject { ["matching_mode"] = "strict", ["url"] = Text(_input, "callback") }),
|
||||
["property_mappings"] = mappings,
|
||||
["issuer_mode"] = "per_provider",
|
||||
["include_claims_in_id_token"] = true,
|
||||
}, token);
|
||||
_provider = Text(provider, "pk");
|
||||
if(Text(provider, "client_id") != Text(_input, "clientId")
|
||||
|| Text(provider, "authentication_flow") != Text(authentication, "pk"))
|
||||
{ throw new InvalidOperationException("The real provider did not retain the configured client identifier."); }
|
||||
JsonObject application = await CreateAsync("/api/v3/core/applications/", new JsonObject
|
||||
{
|
||||
["name"] = Text(_input, "project"), ["slug"] = Text(_input, "project"),
|
||||
["provider"] = provider["pk"]!.DeepClone(), ["policy_engine_mode"] = "all",
|
||||
}, token);
|
||||
if(Text(application, "provider") != _provider)
|
||||
{ throw new InvalidOperationException("The real provider application binding differs from the configured provider."); }
|
||||
}
|
||||
|
||||
private async Task<JsonObject> CreateUserAsync(string usernameKey, string passwordKey, JsonArray groups, CancellationToken token)
|
||||
{
|
||||
JsonObject user = await CreateAsync("/api/v3/core/users/", new JsonObject
|
||||
{
|
||||
["username"] = Text(_input, usernameKey), ["name"] = "Synthetic native qualification subject",
|
||||
["email"] = Text(_input, usernameKey) + "@example.invalid", ["is_active"] = true,
|
||||
["type"] = "internal", ["groups"] = groups,
|
||||
}, token);
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("POST", "/api/v3/core/users/" + Text(user, "pk") + "/set_password/",
|
||||
new JsonObject { ["password"] = Text(_input, passwordKey) }, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic account password configuration failed."); }
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<JsonArray> GrantsAsync(string kind, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", "/api/v3/oauth2/" + kind + "/?provider=" + _provider + "&user=" + _authorizedUser, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
if(records.Count is 0 or > 20) { throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large."); }
|
||||
foreach(JsonNode? node in records)
|
||||
{
|
||||
JsonObject record = node!.AsObject();
|
||||
string provider = record["provider"] is JsonObject nested ? Text(nested, "pk") : Text(record, "provider");
|
||||
if(provider != _provider || Text(record["user"]!.AsObject(), "pk") != _authorizedUser)
|
||||
{ throw new InvalidOperationException("The provider grant escaped the synthetic subject scope."); }
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
internal async Task<HashSet<string>> AccessGrantIdsAsync(CancellationToken token)
|
||||
=> (await GrantsAsync("access_tokens", token)).Select(node => Text(node!.AsObject(), "pk")).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
internal async Task RevokeRefreshGrantsAsync(CancellationToken token)
|
||||
{
|
||||
foreach(JsonNode? node in await GrantsAsync("refresh_tokens", token))
|
||||
{
|
||||
string id = Text(node!.AsObject(), "pk");
|
||||
if(!Guid.TryParse(id, out _) && !long.TryParse(id, out _)) { throw new InvalidOperationException("Unexpected provider grant identifier."); }
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("DELETE", "/api/v3/oauth2/refresh_tokens/" + id + "/", null, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic provider revocation failed."); }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _admin.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -eu
|
||||
attempt=0
|
||||
while [ ! -s /data/caddy/pki/authorities/local/root.crt ]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then
|
||||
echo 'The fixture public CA certificate did not become ready.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Never copy the key or mount the private CA volume into a client/server.
|
||||
cp /data/caddy/pki/authorities/local/root.crt /trust/root.crt
|
||||
chmod 0644 /trust/root.crt
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2NativeSessionLoginMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
|
||||
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.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.RedirectStateMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionRefreshTokenMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSessionRefreshToken;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.ForbiddenResponse;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Opt-in container fixture for the pinned upstream native-session candidate.
|
||||
* Standard OAuth2 code exchange, session storage and encrypted renewal are used
|
||||
* unchanged. No installed pairing, password authentication double or printer.
|
||||
*/
|
||||
public final class NativeAuthHost extends AbstractQQQApplication
|
||||
{
|
||||
private static final String TENANT = "printing-native-dev";
|
||||
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
|
||||
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
|
||||
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
|
||||
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
|
||||
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
|
||||
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
|
||||
private final PrintingService service = new PrintingService(source, TENANT);
|
||||
|
||||
private NativeAuthHost() throws Exception
|
||||
{
|
||||
if(port < 1024 || port > 65535)
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported internal fixture port");
|
||||
}
|
||||
service.initialize();
|
||||
try(Connection connection = source.getConnection(); var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS userSession (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, uuid VARCHAR(40) NOT NULL UNIQUE, userId VARCHAR(100), accessToken CLOB, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS redirectState (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, state VARCHAR(45) NOT NULL UNIQUE, redirectUri VARCHAR(4096), createDate TIMESTAMP WITH TIME ZONE)");
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS userSessionRefreshToken (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, sessionUuid VARCHAR(40) NOT NULL UNIQUE, refreshToken CLOB, refreshTokenExpiresAt TIMESTAMP WITH TIME ZONE, sessionStartedAt TIMESTAMP WITH TIME ZONE, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
NativeAuthHost host = new NativeAuthHost();
|
||||
QApplicationLauncher.run(host, new QApplicationLauncherConfig()
|
||||
.withServerCustomizer(host::configure));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.setFailOnMetaDataProducerError(true);
|
||||
instance.setHomeAppName("printing");
|
||||
instance.setBranding(new QBrandingMetaData().withAppName("QQQ native authentication fixture"));
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("fixture")
|
||||
.withDatabaseName("printing-native-auth").withUsername("sa").withPassword(databasePassword).withJdbcUrl(jdbcUrl));
|
||||
instance.addTable(new UserSessionMetaDataProducer(backendName).produce(instance));
|
||||
instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance));
|
||||
instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance));
|
||||
|
||||
String issuer = required("NATIVE_AUTH_ISSUER");
|
||||
URI issuerUri = URI.create(issuer);
|
||||
if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null
|
||||
|| issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/"))
|
||||
{
|
||||
throw new IllegalArgumentException("An explicit HTTPS fixture issuer ending in slash is required");
|
||||
}
|
||||
OAuth2AuthenticationMetaData authentication = new OAuth2AuthenticationMetaData()
|
||||
.withBaseUrl(issuer).withExternalBaseUrl(issuer)
|
||||
.withClientId(required("NATIVE_AUTH_CLIENT_ID")).withClientSecret(required("NATIVE_AUTH_CLIENT_SECRET"))
|
||||
.withScopes("openid profile email offline_access")
|
||||
.withUserSessionTableName(UserSession.TABLE_NAME).withRedirectStateTableName(RedirectStateMetaDataProducer.TABLE_NAME)
|
||||
.withRefreshTokenTableName(UserSessionRefreshToken.TABLE_NAME)
|
||||
.withRefreshTokensEnabled(true).withRefreshTokenSkewSeconds(0).withMaxSessionLifetimeSeconds(3600)
|
||||
.withRefreshTokenEncryptionKey(required("NATIVE_QQQ_REFRESH_KEY")).withRefreshTokenEncryptionKeyId("fixture-1")
|
||||
.withNativeSessionLogin(new OAuth2NativeSessionLoginMetaData().withIssuer(issuer)
|
||||
.withDiscoveryUrl(issuer + ".well-known/openid-configuration")
|
||||
.withRedirectUris(List.of(required("NATIVE_AUTH_REDIRECT_URI"))));
|
||||
authentication.withName("printing-native-provider").withSessionStoreEnabled(false)
|
||||
.withCustomizer(new QCodeReference(PrintingPermissions.class));
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), authentication);
|
||||
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service))
|
||||
.produce(instance, "printing");
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(backendName.equals(table.getBackendName()))
|
||||
{
|
||||
table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName().toUpperCase(Locale.ROOT)));
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
field.withBackendName(field.getName().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void configure(QApplicationJavalinServer server)
|
||||
{
|
||||
// Only the owned TLS proxy is published; this HTTP listener remains inside
|
||||
// the Compose network. Forwarded HTTPS is required even for discovery.
|
||||
server.withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(service)))
|
||||
.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
config.jetty.host = "0.0.0.0";
|
||||
config.http.maxRequestSize = 1048576;
|
||||
config.routes.before(this::guardOrigin);
|
||||
});
|
||||
}
|
||||
|
||||
private void guardOrigin(Context context)
|
||||
{
|
||||
if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host"))
|
||||
|| !"https".equals(context.header("X-Forwarded-Proto")))
|
||||
{
|
||||
throw new ForbiddenResponse("The fixture requires its configured HTTPS proxy origin");
|
||||
}
|
||||
String suppliedOrigin = context.header("Origin");
|
||||
if(suppliedOrigin != null && !origin.toString().equals(suppliedOrigin))
|
||||
{
|
||||
throw new ForbiddenResponse("Cross-origin fixture requests are not allowed");
|
||||
}
|
||||
if("cross-site".equalsIgnoreCase(context.header("Sec-Fetch-Site"))
|
||||
|| context.header("X-QQQ-In-Process-Session") != null)
|
||||
{
|
||||
throw new ForbiddenResponse("Only standard native session authentication is allowed");
|
||||
}
|
||||
boolean unsafe = !Set.of("GET", "HEAD", "OPTIONS").contains(context.method().name());
|
||||
boolean cookie = context.cookie("sessionUUID") != null || context.cookie("sessionId") != null;
|
||||
if(unsafe && cookie && suppliedOrigin == null)
|
||||
{
|
||||
throw new ForbiddenResponse("Cookie mutations require the configured Origin");
|
||||
}
|
||||
}
|
||||
|
||||
/** Only maps claims after the unchanged standard OAuth2 module authenticates. */
|
||||
public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
session.setPermissions(Set.of());
|
||||
session.withSecurityKeyValue("printing_tenant", TENANT);
|
||||
Object claims = context.get("jwtPayloadJsonObject");
|
||||
if(session.getUser() != null && claims instanceof JSONObject payload)
|
||||
{
|
||||
JSONArray groups = payload.optJSONArray("groups");
|
||||
if(groups != null && groups.toList().contains("printing_admin"))
|
||||
{
|
||||
session.setPermissions(Set.of("printing_admin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static URI httpsOrigin(String value)
|
||||
{
|
||||
URI parsed = URI.create(value);
|
||||
if(!"https".equals(parsed.getScheme()) || parsed.getHost() == null || parsed.getRawUserInfo() != null
|
||||
|| parsed.getRawQuery() != null || parsed.getRawFragment() != null || !parsed.getRawPath().isEmpty())
|
||||
{
|
||||
throw new IllegalArgumentException("A root HTTPS fixture origin without trailing slash is required");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Set the synthetic fixture variable " + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -eu
|
||||
# This first request must fail with the ordinary TLS validator, before trust.
|
||||
dotnet /app/NativeAuth.dll assert-untrusted
|
||||
test -s /trust/root.crt
|
||||
cp /trust/root.crt /usr/local/share/ca-certificates/printing-native-fixture.crt
|
||||
update-ca-certificates >/dev/null 2>&1
|
||||
exec dotnet /app/NativeAuth.dll test
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -eu
|
||||
# Only this disposable container's JRE trust store is changed. The mounted file
|
||||
# is the public root certificate; the CA private key is never mounted here.
|
||||
test -s /trust/root.crt
|
||||
keytool -importcert -noprompt -alias printing-native-fixture -file /trust/root.crt -cacerts -storepass changeit >/dev/null 2>&1
|
||||
exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.NativeAuthHost
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -eu
|
||||
case "$TEST_RUN" in run-[a-f0-9]*) ;; *) echo 'Invalid owned test run' >&2; exit 2;; esac
|
||||
mkdir -p "/work/$TEST_RUN/s3"
|
||||
java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.DevHost --wait-storage
|
||||
java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' S3PrintingQualification "/work/$TEST_RUN/s3" http://127.0.0.1:9000 "$TEST_RUN"
|
||||
exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.WorkloadFixture "/work/$TEST_RUN" "$WORKLOAD_MODE"
|
||||
@@ -0,0 +1,71 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# No Docker or HTTP calls: these regressions exercise private bootstrap state.
|
||||
BeforeAll {
|
||||
$script:authScript = Join-Path $PSScriptRoot '../Invoke-Authentik.ps1'
|
||||
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
|
||||
}
|
||||
|
||||
Describe 'Authentik development bootstrap' {
|
||||
BeforeEach {
|
||||
$state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $state | Out-Null
|
||||
$envFile = Join-Path $state 'compose.env'
|
||||
$prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`nQQQ_HTTP_PORT=19081`n"
|
||||
[IO.File]::WriteAllText($envFile, $prefix)
|
||||
}
|
||||
|
||||
It 'preserves common environment values and creates a matching hashed admin password without console output' {
|
||||
$output = @(& $authScript -Action Initialize -StateDirectory $state -ProjectName $project)
|
||||
$output.Count | Should -Be 0
|
||||
$environment = Get-Content -LiteralPath $envFile -Raw
|
||||
$environment.StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue
|
||||
[regex]::Matches($environment, '(?m)^AUTHENTIK_[A-Z_]+=').Count | Should -Be 5
|
||||
$private = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json
|
||||
$match = [regex]::Match($environment, '(?m)^AUTHENTIK_BOOTSTRAP_PASSWORD_HASH=''pbkdf2_sha256\$1000000\$([^$]+)\$([^'']+)''')
|
||||
$match.Success | Should -BeTrue
|
||||
$actual = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($private.adminPassword, [Text.Encoding]::UTF8.GetBytes($match.Groups[1].Value), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32))
|
||||
[string]::Equals($actual, $match.Groups[2].Value, [StringComparison]::Ordinal) | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not rotate credentials or append duplicate keys on repeated initialization' {
|
||||
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
|
||||
$beforeEnvironment = (Get-FileHash -LiteralPath $envFile).Hash
|
||||
$beforeSecrets = (Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash
|
||||
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
|
||||
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $beforeEnvironment
|
||||
(Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash | Should -Be $beforeSecrets
|
||||
}
|
||||
|
||||
It 'rejects a changed project or port without overwriting private state' {
|
||||
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
|
||||
$before = (Get-FileHash -LiteralPath $envFile).Hash
|
||||
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project -Port 19011 } | Should -Throw '*different project or port*'
|
||||
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*different project or port*'
|
||||
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before
|
||||
}
|
||||
|
||||
It 'rejects partial initialization rather than assigning different secrets to an existing database' {
|
||||
Add-Content -LiteralPath $envFile -Value 'AUTHENTIK_SECRET_KEY=synthetic'
|
||||
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*Partial Authentik initialization*'
|
||||
Test-Path -LiteralPath (Join-Path $state 'auth-secrets.json') | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'rejects missing environment keys after initialization' {
|
||||
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
|
||||
[IO.File]::WriteAllText($envFile, $prefix)
|
||||
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*incomplete or ambiguous*'
|
||||
}
|
||||
|
||||
It 'withholds malformed private configuration contents' {
|
||||
[IO.File]::WriteAllText((Join-Path $state 'auth-secrets.json'), '{"private":"synthetic-value-to-withhold"')
|
||||
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*'
|
||||
}
|
||||
|
||||
It 'refuses a state directory inside the source checkout before writing' {
|
||||
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
{ & $authScript -Action Initialize -StateDirectory $repository -ProjectName $project } | Should -Throw '*outside the source checkout*'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Ownership regressions use mocked Docker metadata; no containers or network calls.
|
||||
BeforeAll {
|
||||
$script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [Management.Automation.Language.Parser]::ParseFile($entrypoint, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count)
|
||||
{
|
||||
throw 'Entrypoint parsing failed.'
|
||||
}
|
||||
foreach ($name in @('Assert-OwnedResource', 'Get-RecordedAuthentikPort'))
|
||||
{
|
||||
$guard = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
|
||||
. ([scriptblock]::Create($guard.Extent.Text))
|
||||
}
|
||||
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
|
||||
function docker
|
||||
{
|
||||
throw 'Unmocked Docker call refused.'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Development lifecycle ownership' {
|
||||
BeforeEach {
|
||||
$script:wrongResource = ''
|
||||
Mock docker {
|
||||
$global:LASTEXITCODE = 0
|
||||
$command = $args -join ' '
|
||||
if ($command.StartsWith('ps -aq', [StringComparison]::Ordinal))
|
||||
{
|
||||
return 'container-id'
|
||||
}
|
||||
if ($command.StartsWith('network ls', [StringComparison]::Ordinal))
|
||||
{
|
||||
return 'network-id'
|
||||
}
|
||||
if ($command.StartsWith('volume ls', [StringComparison]::Ordinal))
|
||||
{
|
||||
return 'volume-id'
|
||||
}
|
||||
if ($script:wrongResource -and $command.Contains($script:wrongResource, [StringComparison]::Ordinal))
|
||||
{
|
||||
return 'another-project'
|
||||
}
|
||||
return $script:project
|
||||
}
|
||||
}
|
||||
It 'accepts only consistently labeled containers, networks and volumes' {
|
||||
{ Assert-OwnedResource } | Should -Not -Throw
|
||||
Should -Invoke docker -Times 6 -Exactly
|
||||
}
|
||||
It 'rejects a foreign container before any cleanup' {
|
||||
$script:wrongResource = 'container-id'
|
||||
{ Assert-OwnedResource } | Should -Throw '*Container ownership mismatch*'
|
||||
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
|
||||
}
|
||||
It 'rejects a foreign network before any cleanup' {
|
||||
$script:wrongResource = 'network-id'
|
||||
{ Assert-OwnedResource } | Should -Throw '*Network ownership mismatch*'
|
||||
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
|
||||
}
|
||||
It 'rejects a foreign volume before any cleanup' {
|
||||
$script:wrongResource = 'volume-id'
|
||||
{ Assert-OwnedResource } | Should -Throw '*Volume ownership mismatch*'
|
||||
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
|
||||
}
|
||||
It 'refuses an existing unowned state directory before contacting Docker' {
|
||||
{ & $entrypoint -Action Stop -StateDirectory $TestDrive } | Should -Throw '*lacks this tool ownership record*'
|
||||
Should -Invoke docker -Times 0
|
||||
}
|
||||
It 'rejects persistent experimental startup before any state creation' {
|
||||
$path = Join-Path $TestDrive 'experimental-start'
|
||||
{ & $entrypoint -Action Start -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*Test-only profile*'
|
||||
Test-Path -LiteralPath $path | Should -BeFalse
|
||||
Should -Invoke docker -Times 0
|
||||
}
|
||||
It 'does not repurpose a saved standard environment for experimental authentication' {
|
||||
$path = Join-Path $TestDrive 'standard-environment'
|
||||
[IO.Directory]::CreateDirectory($path) | Out-Null
|
||||
@{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = $project; authentik = $false } |
|
||||
ConvertTo-Json | Set-Content (Join-Path $path 'owner.json')
|
||||
{ & $entrypoint -Action Test -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*cannot be repurposed*'
|
||||
Should -Invoke docker -Times 0
|
||||
}
|
||||
It 'refuses a forged project name before contacting Docker' {
|
||||
$path = Join-Path $TestDrive 'forged'
|
||||
[IO.Directory]::CreateDirectory($path) | Out-Null
|
||||
@{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = 'unrelated'; authentik = $false } | ConvertTo-Json | Set-Content (Join-Path $path 'owner.json')
|
||||
{ & $entrypoint -Action Stop -StateDirectory $path } | Should -Throw '*State ownership mismatch*'
|
||||
Should -Invoke docker -Times 0
|
||||
}
|
||||
It 'restores a nondefault Authentik port from saved state' {
|
||||
$script:envFile = Join-Path $TestDrive 'ports.env'
|
||||
[IO.File]::WriteAllText($envFile, "QQQ_HTTP_PORT=19081`nAUTHENTIK_HTTP_PORT=19011`n")
|
||||
Get-RecordedAuthentikPort | Should -Be 19011
|
||||
}
|
||||
It 'rejects ambiguous saved port configuration' {
|
||||
$script:envFile = Join-Path $TestDrive 'ambiguous.env'
|
||||
[IO.File]::WriteAllText($envFile, "AUTHENTIK_HTTP_PORT=19010`nAUTHENTIK_HTTP_PORT=19011`n")
|
||||
{ Get-RecordedAuthentikPort } | Should -Throw '*missing or invalid*'
|
||||
}
|
||||
It 'rejects a state ancestor junction or symlink before writing or contacting Docker' {
|
||||
$target = Join-Path $TestDrive 'target'
|
||||
$link = Join-Path $TestDrive 'linked'
|
||||
[IO.Directory]::CreateDirectory($target) | Out-Null
|
||||
if ($IsWindows)
|
||||
{
|
||||
New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop | Out-Null
|
||||
}
|
||||
else
|
||||
{
|
||||
New-Item -ItemType SymbolicLink -Path $link -Target $target -ErrorAction Stop | Out-Null
|
||||
}
|
||||
try
|
||||
{
|
||||
{ & $entrypoint -Action Start -StateDirectory (Join-Path $link 'new-state') } | Should -Throw '*symbolic link or reparse point*'
|
||||
Test-Path -LiteralPath (Join-Path $target 'new-state') | Should -BeFalse
|
||||
Should -Invoke docker -Times 0
|
||||
}
|
||||
finally
|
||||
{
|
||||
[IO.Directory]::Delete($link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# No Docker, TLS, provider or QQQ calls; only private input and result validation.
|
||||
BeforeAll {
|
||||
$script:nativeScript = Join-Path $PSScriptRoot '../Invoke-NativeAuth.ps1'
|
||||
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
|
||||
$script:requiredChecks = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied')
|
||||
}
|
||||
|
||||
Describe 'Experimental native provider qualification state' {
|
||||
BeforeEach {
|
||||
$state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $state | Out-Null
|
||||
$envFile = Join-Path $state 'compose.env'
|
||||
$prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`n"
|
||||
[IO.File]::WriteAllText($envFile, $prefix)
|
||||
@{ project = $project; clientId = $project; bootstrapToken = 'synthetic-admin'; clientSecret = 'synthetic-client'; userPassword = 'synthetic-user' } |
|
||||
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-secrets.json')
|
||||
$script:report = @{
|
||||
status = 'PASS'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $project
|
||||
coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629'
|
||||
clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c'; checks = $requiredChecks
|
||||
}
|
||||
$script:resultFile = Join-Path $state 'native-auth-result.json'
|
||||
}
|
||||
|
||||
It 'preserves common state and creates only the exact owned destinations without console secrets' {
|
||||
$output = @(& $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project)
|
||||
$output.Count | Should -Be 0
|
||||
(Get-Content -LiteralPath $envFile -Raw).StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue
|
||||
$inputData = Get-Content -LiteralPath (Join-Path $state 'native-input.json') -Raw | ConvertFrom-Json
|
||||
$inputData.qqqOrigin | Should -Be 'https://qqq.printing.internal'
|
||||
$inputData.callback | Should -Be 'https://callback.printing.internal/oauth/callback'
|
||||
$inputData.project | Should -Be $project
|
||||
(Get-Content -LiteralPath $script:resultFile -Raw | ConvertFrom-Json).status | Should -Be 'NOT_RUN'
|
||||
$before = (Get-FileHash -LiteralPath $envFile).Hash
|
||||
{ & $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*fresh owned environment*'
|
||||
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before
|
||||
}
|
||||
|
||||
It 'refuses credentials owned by another project before writing native input' {
|
||||
{ & $nativeScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*does not match*'
|
||||
Test-Path -LiteralPath (Join-Path $state 'native-input.json') | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'accepts the exact source and complete bounded report' {
|
||||
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
|
||||
& $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project | Should -Match 'candidate is unpublished'
|
||||
}
|
||||
|
||||
It 'rejects a passing report from a different source' {
|
||||
$script:report.coreSource = '0000000000000000000000000000000000000000'
|
||||
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
|
||||
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*exact source*'
|
||||
}
|
||||
|
||||
It 'rejects missing renewal evidence instead of treating successful login as complete' {
|
||||
$script:report.checks = @($requiredChecks | Where-Object { $_ -ne 'server-refresh-after-expiry' })
|
||||
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
|
||||
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*required checks*'
|
||||
}
|
||||
|
||||
It 'rejects malformed or oversized reports without echoing their contents' {
|
||||
[IO.File]::WriteAllText($script:resultFile, '{"private":"do-not-echo"')
|
||||
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*'
|
||||
[IO.File]::WriteAllText($script:resultFile, ('x' * 65537))
|
||||
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*bounded size*'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
BeforeAll {
|
||||
$path = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw 'Entrypoint parsing failed.' }
|
||||
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-ProviderErrorSummary' }, $true)
|
||||
. ([scriptblock]::Create($function.Extent.Text))
|
||||
}
|
||||
|
||||
Describe 'Private provider diagnostic redaction' {
|
||||
It 'retains source coordinates but excludes exception values, locals and request data' {
|
||||
$record = @{event = 'secret-request'; exception = @(@{exc_type = 'KeyError'; exc_value = 'secret-value'; frames = @(
|
||||
@{filename = '/authentik/flows/views/executor.py'; lineno = 42; name = 'post'; locals = @{password = 'secret-password'}; source = 'secret-source'},
|
||||
@{filename = '/outside/secret-path.py'; lineno = 3; name = 'unsafe'}
|
||||
)
|
||||
})
|
||||
} | ConvertTo-Json -Depth 8 -Compress
|
||||
$result = ConvertTo-ProviderErrorSummary @($record, 'secret-unstructured-line')
|
||||
$result.exceptionTypes | Should -Be @('KeyError')
|
||||
$result.frames.Count | Should -Be 1
|
||||
$result.frames[0].function | Should -Be 'post'
|
||||
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret|password|outside|locals|source'
|
||||
}
|
||||
It 'bounds frames and does not retain malformed or query-bearing coordinates' {
|
||||
$frames = @(1..20 | ForEach-Object { @{filename = '/authentik/test.py'; lineno = $_; name = 'get'} })
|
||||
$record = @{exception = @(@{exc_type = 'ValueError'; frames = $frames})} | ConvertTo-Json -Depth 8 -Compress
|
||||
(ConvertTo-ProviderErrorSummary @($record)).frames.Count | Should -Be 12
|
||||
$bad = @{exception = @(@{exc_type = 'secret?Error'; frames = @(@{filename = '/authentik/test.py?secret'; lineno = 1; name = 'get'})})} | ConvertTo-Json -Depth 8 -Compress
|
||||
$result = ConvertTo-ProviderErrorSummary @($bad, '{broken', 'null')
|
||||
$result.exceptionTypes.Count | Should -Be 0
|
||||
$result.frames.Count | Should -Be 0
|
||||
}
|
||||
It 'records only fixed known warning flags from unstructured output' {
|
||||
$result = ConvertTo-ProviderErrorSummary @('Invalid client identifier secret-query', 'Non-applicable authentication flow secret', 'failed to resolve application secret')
|
||||
$result.warnings.invalidClientIdentifier | Should -BeTrue
|
||||
$result.warnings.nonApplicableAuthenticationFlow | Should -BeTrue
|
||||
$result.warnings.failedToResolveApplication | Should -BeTrue
|
||||
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret'
|
||||
}
|
||||
It 'retains fixed DRF exception names without their messages' {
|
||||
$record = @{exception = @(@{exc_type = 'PermissionDenied'; exc_value = 'secret-csrf-value'; frames = @()})} | ConvertTo-Json -Depth 5 -Compress
|
||||
$result = ConvertTo-ProviderErrorSummary @($record)
|
||||
$result.exceptionTypes | Should -Be @('PermissionDenied')
|
||||
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
BeforeAll {
|
||||
$script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
|
||||
function docker { throw 'Unmocked Docker call refused.' }
|
||||
}
|
||||
|
||||
Describe 'Early workload client failure' {
|
||||
BeforeEach {
|
||||
$script:state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
|
||||
@{success = $false; stage = 'native_login_and_channels'; error = 'QqqSessionException'; reason = 'secret-value https://private.invalid/?token=secret'; completed = 0} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json')
|
||||
Mock docker {
|
||||
$global:LASTEXITCODE = 0
|
||||
if ($args[0] -eq 'compose')
|
||||
{
|
||||
if ($args -contains 'ps')
|
||||
{
|
||||
if ($args[-1] -eq 'test-client') { return ('c' * 64) }
|
||||
if ($args[-1] -eq 'test-server') { return ('b' * 64) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($args[0] -eq 'inspect')
|
||||
{
|
||||
if ($args[1] -eq ('c' * 64)) { return 'false|17' }
|
||||
throw 'SERVER_WAIT_REACHED'
|
||||
}
|
||||
if ($args[0] -eq 'cp')
|
||||
{
|
||||
$fixtureRoot = [IO.Directory]::GetParent([IO.Directory]::GetParent([IO.Directory]::GetParent($args[-1]).FullName).FullName).FullName
|
||||
Copy-Item -LiteralPath (Join-Path $fixtureRoot 'client-fixture.json') -Destination $args[-1]
|
||||
return
|
||||
}
|
||||
if ($args[0] -in @('ps', 'network', 'volume')) { return }
|
||||
throw 'Unexpected fake Docker operation.'
|
||||
}
|
||||
}
|
||||
It 'surfaces the client failure before server waiting and still cleans owned containers' {
|
||||
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at native_login_and_channels (QqqSessionException)*'
|
||||
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
|
||||
Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly
|
||||
$result = Get-ChildItem -LiteralPath $script:state -Filter result.json -Recurse
|
||||
(Get-Content -LiteralPath $result.FullName -Raw | ConvertFrom-Json).reason | Should -Be 'secret-value https://private.invalid/?token=secret'
|
||||
}
|
||||
It 'does not print unrecognized stage or exception text' {
|
||||
@{success = $false; stage = 'secret-stage https://private.invalid/'; error = 'secret-error'} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json')
|
||||
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*'
|
||||
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
|
||||
}
|
||||
It 'fails safely for <Kind> diagnostics and still cleans owned containers' -TestCases @(
|
||||
@{Kind = 'malformed'; Content = '{secret-malformed-json'},
|
||||
@{Kind = 'null'; Content = 'null'},
|
||||
@{Kind = 'oversized'; Content = (@{success = $false; stage = 'input'; error = 'JsonException'; reason = ('s' * 65536)} | ConvertTo-Json -Compress)}
|
||||
) {
|
||||
param($Content)
|
||||
$Content | Set-Content (Join-Path $TestDrive 'client-fixture.json')
|
||||
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*'
|
||||
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
|
||||
Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -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,104 @@
|
||||
# Server qualification — 2026-11-01
|
||||
|
||||
The published server **0.3.0-rc.1** and published Windows **0.3.1-rc.1** Core runtime
|
||||
completed the following isolated qualification. No live server, installed service,
|
||||
real document, physical printer or external cloud bucket was changed.
|
||||
|
||||
## Network workload
|
||||
|
||||
These measurements describe the earlier released-artifact fixture runs. The
|
||||
portable [Docker developer tooling](../dev/README.md) records its own per-run
|
||||
measurements and includes separate default smoke and opt-in burst commands.
|
||||
|
||||
The existing Java/.NET interoperability fixture was extended with synthetic work.
|
||||
It used the actual QQQ table-authenticated host, native session/device binding,
|
||||
WebSocket channel, released Core `PrintWorker`, HTTPS-client code over explicitly
|
||||
allowed loopback HTTP, file downloads, and WebSocket receipt acknowledgements.
|
||||
Only the physical printer and local fixture journal were substitutes. The server
|
||||
persisted real SQL jobs and receipts. Admission limits remained enabled.
|
||||
|
||||
| Check | Observed result |
|
||||
| --- | --- |
|
||||
| Burst workload | 10,000 jobs; two tenants; eight devices |
|
||||
| Documents | 1,021-byte synthetic ZPL; exact downloaded bytes checked |
|
||||
| Completion | 10,000 SPOOLED rows; 30,000 durable event rows; no pending work |
|
||||
| Replay | 400 submit and 400 receipt replays; no duplicates |
|
||||
| Isolation | Foreign device binding and tenant job access refused |
|
||||
| Accounting | Retained bytes exact; no orphan upload rows |
|
||||
| Duration | 96.12 seconds including setup; about 104 jobs/second |
|
||||
| Maximum backlog | 7,798 committed jobs; final backlog zero |
|
||||
| Working set | Server peak 680 MiB; client peak approximately 85 MiB |
|
||||
| Cold persistence | 10,000 rows present; read-only check left DB unchanged |
|
||||
|
||||
Burst enqueue-to-terminal-ACK latency was p50 43.30 seconds, p95 62.84 seconds,
|
||||
p99 64.66 seconds. These values include intentional queue backlog; they are not
|
||||
idle notification latency.
|
||||
|
||||
A separate **40-job one-at-a-time** run measured:
|
||||
|
||||
| Interval | Median | 95th percentile |
|
||||
| --- | ---: | ---: |
|
||||
| Received notification to claim completion | 8.72 ms | 27.18 ms |
|
||||
| Received notification to verified download completion | 29.74 ms | 49.07 ms |
|
||||
| Enqueue to durable terminal receipt acknowledgement | 49.65 ms | 76.56 ms |
|
||||
|
||||
The fixture does not periodically poll for jobs. A received notification wakes a
|
||||
finite claim/drain operation. Liveness PING messages do not query jobs. These are
|
||||
HTTP/WS loopback results, **not HTTPS/WSS, remote network, physical print-speed,
|
||||
large-document sustained throughput, or long-duration soak qualification**.
|
||||
The fixture journal is not the installed Windows SQLite journal; its recovery
|
||||
behavior is covered separately by the Windows tests.
|
||||
|
||||
## Existing storage path, current release
|
||||
|
||||
The existing S3 qualification harness was rerun against this exact published
|
||||
server and its unchanged 204-JAR runtime. All **65 checks / six document round
|
||||
trips** passed through native QQQ `StorageAction`, `S3StorageAction`, and AWS SDK
|
||||
against one disposable local MinIO container. The run includes actual multipart
|
||||
upload above 5 MiB, exact length/hash/MIME, tenant namespaces, immutable-reference
|
||||
validation, stable replay, completion-age retention, and protection of active,
|
||||
failed, uncertain, fresh, foreign and unrelated objects. The owned container and
|
||||
its ephemeral data were removed after identity verification.
|
||||
|
||||
This is S3-compatible data-plane evidence. It does not qualify AWS IAM, remote
|
||||
TLS, bucket policies, versioning/Object Lock, or a configured production storage
|
||||
target. The existing platform-specific key-layout binding guard remains required;
|
||||
no shared QQQ library or cross-OS migration behavior was changed.
|
||||
|
||||
## Repeatable service regression
|
||||
|
||||
`PrintingWorkloadTest` is an opt-in file-backed regression, separate from the
|
||||
network fixture. It runs 10,000 real service lifecycles through four pooled SQL
|
||||
connections, tests replay and tenant pagination, verifies exact event/byte counts,
|
||||
and closes every connection before reopening without schema initialization.
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -Pui-demo -Dprinting.qualification.load=true verify
|
||||
```
|
||||
|
||||
The protocol fixtures must be present at the usual sibling checkout; alternatively
|
||||
set `-Dprinting.protocol.fixtures=/path/to/reviewed/fixtures`.
|
||||
The full gate passed **119 tests, zero skips and zero Checkstyle violations**.
|
||||
A subsequent exact event-count assertion improvement passed its focused workload
|
||||
rerun. Default builds skip this explicit workload test; ordinary regression tests
|
||||
remain unchanged. The separate development workflow runs cross-platform script
|
||||
checks and the real 40-job/S3 Docker smoke on pull requests and main pushes; its
|
||||
experimental native-auth run is manual. Hosted results must be checked on the
|
||||
submitted commit. No application implementation or dependency changed.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
The user's current development/test target is local Docker. AWS/account/IAM or
|
||||
remote deployment provisioning is not a prerequisite for this change.
|
||||
|
||||
The separate [experimental native-auth profile](../dev/native-auth/README.md)
|
||||
passed real Authentik login, denied-user/wrong-Origin checks, logout, short token
|
||||
renewal and provider revocation using container-private HTTPS and the pinned
|
||||
unreleased QQQ API 1.9 candidate. It does not upgrade this regression's released
|
||||
RC.20 dependency or qualify the installed Windows browser callback. The workload
|
||||
and S3 measurements above remain HTTP/WS and MinIO evidence, respectively.
|
||||
|
||||
Long-duration soak, mid-session account/role changes and sustained physical
|
||||
throughput remain separate from these bounded tests and previously confirmed
|
||||
individual physical prints. No application implementation, dependency or
|
||||
published package version changes in this test/developer-tooling change.
|
||||
@@ -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,139 @@
|
||||
# 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
|
||||
default workload's printing-major-2 gate, accepting stable 2.1.0 while retaining
|
||||
the exact experimental API 1.9/native grant requirement. Its previous exact-2.0.0
|
||||
check reproduced a rejection before the fix. Native and default client image
|
||||
builds pass 12 and nine fake-HTTP compatibility checks respectively, with zero
|
||||
warnings/errors. One fresh provider run against server source `3592dec`
|
||||
(printing API 2.1.0) and the reviewed probe correction passed all eight checks.
|
||||
All source hashes remained unchanged through completion; independent cleanup
|
||||
found zero owned containers, volumes and networks. No installed runtime,
|
||||
physical printer or host trust setting changed.
|
||||
|
||||
The developer tooling is published on `main` through [PR #21](https://github.com/QRun-IO/qbit-printing/pull/21),
|
||||
merged as `3592decba8087439aff5d1b75a9726c72f460cb9`; its tree matches tested
|
||||
head `def8d961f3d82a1235f8e45ffbeacb0fbe79c421`. The final hosted
|
||||
[development run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370247)
|
||||
passed the actual 40-job HTTP/WS smoke and 65 S3 checks, nine pinned-Core discovery
|
||||
compatibility cases, and all 33 script tests on each of Windows and Ubuntu.
|
||||
The [Java run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370253)
|
||||
reported 133 tests: 132 executed, one intentionally skipped opt-in workload test,
|
||||
zero failures/errors, and zero Checkstyle violations. That workload separately
|
||||
passed on the combined source. These are development-tooling publication results,
|
||||
not a server release or live runtime upgrade. The experimental API 1.9 candidate
|
||||
remains opt-in and is not adopted into the released application dependencies.
|
||||
|
||||
The current qualification scope is local Docker only. The new `dev/Invoke-Dev.ps1`
|
||||
provides owned Start/Test/Stop commands for a native QQQ TABLE host, dedicated
|
||||
MinIO storage and optional real Authentik/PostgreSQL. Source images and the client
|
||||
Core archive are pinned. Generated credentials stay in private state outside the
|
||||
checkout; no installed connector, live database or physical printer is used.
|
||||
|
||||
Portable Docker checks passed: the default 40-job smoke, 65 actual QQQ S3 backend
|
||||
checks, and an opt-in 10,000-job/two-tenant/eight-client run with 30,000 durable
|
||||
server receipts and 400 submission plus 400 receipt replays. The burst completed
|
||||
in 147.856 seconds (about 67.63 jobs/second); its enqueue-to-ACK p95 was 32.984
|
||||
seconds while work intentionally queued. This is HTTP/WS with a fake file printer
|
||||
and a simple fixture client journal, not TLS, hardware or a long soak claim.
|
||||
|
||||
Native TABLE login, identity, frontend asset and logout passed. Real Authentik
|
||||
configuration/discovery passed; a registered redirect continued to login and the
|
||||
otherwise-identical unregistered redirect was rejected. The released RC.20 host
|
||||
still advertises API 1.8 without the native session grant: browser login, renewal
|
||||
and the Windows callback are not established by these checks. All owned test
|
||||
containers, volumes and networks were removed. Lifecycle/bootstrap regressions
|
||||
passed 28 cases including experimental-profile refusal and diagnostic-redaction
|
||||
checks; scripts passed parser and PSScriptAnalyzer checks.
|
||||
|
||||
The separate opt-in native-auth profile passed eight actual checks against the
|
||||
immutable upstream API 1.9 candidate `ecf0f550a0357cec6387c2b54efca128a405a629`:
|
||||
ordinary TLS refusal before container-only CA installation, typed-grant discovery,
|
||||
real Authentik login through the production Core PKCE client, wrong-Origin cookie
|
||||
rejection, denied-user permissions, logout replay refusal, server-side renewal
|
||||
after access-token expiry and denial after provider refresh-token revocation.
|
||||
The final run exited zero and all owned containers, volumes and networks were
|
||||
removed. Earlier failed fixture attempts remain in private evidence; their fixes
|
||||
were standard-flow readiness/binding, bounded provider redirects and the correct
|
||||
Authentik CSRF header. No authentication check was disabled.
|
||||
|
||||
This experimental candidate is not the default released RC.20 runtime or a
|
||||
published production dependency upgrade. Container HTTPS and automated real
|
||||
provider flows do not qualify the Windows browser callback UI, host trust,
|
||||
months-long operation or physical printing. See [local development](../dev/README.md),
|
||||
[provider limits](../dev/AUTHENTIK.md) and [native qualification](../dev/native-auth/README.md).
|
||||
Earlier release checkpoints below are historical. This developer-tooling merge
|
||||
did not publish a new server product release or change the live paired runtime.
|
||||
|
||||
## Opt-in service workload regression — 2026-11-01
|
||||
|
||||
The combined test/development change also includes a 10,000-job, two-tenant,
|
||||
eight-lane file-backed service regression with replay, exact accounting,
|
||||
pagination and cold reopen checks. It is opt-in and never accesses a printer.
|
||||
The original isolated full gate passed 119 tests with zero Checkstyle violations;
|
||||
the final exact receipt-count assertion passed its focused rerun. Its source is
|
||||
unchanged when integrated here, and the combined checkout passed one focused
|
||||
workload test with zero Checkstyle violations. The earlier released-artifact
|
||||
network run and
|
||||
40-job idle latency measurements are recorded in
|
||||
[qualification evidence](QUALIFICATION-2026-11-01.md), separately from the portable
|
||||
Docker run above. No runtime source, dependencies or version changed.
|
||||
|
||||
## Active device controls and standard QQQ authentication
|
||||
|
||||
Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Owned source notice check
|
||||
|
||||
Run these commands from the repository root with the existing Python 3 interpreter:
|
||||
|
||||
```text
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
```
|
||||
|
||||
The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files.
|
||||
|
||||
Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails.
|
||||
|
||||
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
|
||||
|
||||
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
|
||||
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard.
|
||||
+54
-1
@@ -1,5 +1,56 @@
|
||||
# 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
|
||||
native-auth probe; reproduce the old 2.1.0 rejection, then pass 12 native and
|
||||
nine default workload fake-HTTP compatibility checks without provider launch.
|
||||
- [x] Rerun the eight-check native provider profile once against printing API 2.1.0
|
||||
after review; all eight passed with unchanged source hashes and owned cleanup.
|
||||
- [x] Supply portable, owned Docker Start/Test/Stop commands with private synthetic
|
||||
credentials and explicit cleanup; leave the installed pairing untouched.
|
||||
- [x] Run the 40-job smoke, 65 real S3 backend checks and 10,000 network jobs with
|
||||
30,000 durable server receipts and replay/isolation assertions.
|
||||
- [x] Qualify real Authentik provider discovery and registered/unregistered redirect
|
||||
controls while reporting the released QQQ 1.8 native-grant absence honestly.
|
||||
- [x] Qualify the separate experimental API 1.9/container-TLS native login,
|
||||
denied-user/wrong-Origin permissions, logout, short renewal and provider revocation.
|
||||
- [x] Include the opt-in 10,000-job pooled-SQL/cold-reopen service regression and
|
||||
preserve separate released-artifact network/idle-latency evidence.
|
||||
- [x] Publish developer tooling through PR #21, merged as
|
||||
`3592decba8087439aff5d1b75a9726c72f460cb9`: hosted 40-job/65-S3 checks,
|
||||
33 script tests on both Windows and Ubuntu, and the ordinary Java gate
|
||||
(133 reported, 132 executed, one opt-in skip, zero failures/Checkstyle violations).
|
||||
Keep experimental API 1.9 artifacts opt-in; this merge does not adopt them into
|
||||
the released application or upgrade the live paired runtime.
|
||||
|
||||
The older release checklists below are historical context. AWS/cloud-account
|
||||
provisioning is outside the user's current local-Docker-only request.
|
||||
|
||||
- [x] Complete native device controls, per-record/selection actions, queue/history, dashboard and legacy-boundary integration tests; 110 tests pass with zero Checkstyle violations.
|
||||
- [x] Complete 25 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs.
|
||||
- [ ] Complete installed candidate acceptance after protected CI.
|
||||
@@ -46,7 +97,9 @@
|
||||
- [x] Implement the dedicated QQQ object-storage document backend; live S3/IAM qualification remains pending configuration.
|
||||
- [ ] Logical destination mappings.
|
||||
- [x] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; included in the passing 28-test v0.1.3-rc.1 gate.
|
||||
- [ ] Qualify sustained throughput separately from synthetic bounded-history tests.
|
||||
- [ ] Qualify long-duration soak and sustained physical throughput separately
|
||||
from the completed bounded network/fake-printer workloads; see
|
||||
[qualification evidence](QUALIFICATION-2026-11-01.md).
|
||||
- [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency.
|
||||
- [x] Implement admin document retention and bounded scheduled cleanup, default 365 days.
|
||||
- [ ] Server-side job expiry and operational alerts.
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-printing</artifactId>
|
||||
<version>0.3.0-rc.1</version>
|
||||
<version>0.4.0-SNAPSHOT</version>
|
||||
<name>QQQ Printing</name>
|
||||
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
|
||||
<properties>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* 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.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A small monospace page writer shared by the record, table-list and text
|
||||
** documents: a heading on the first page, a running title on later pages,
|
||||
** lines that break onto new pages (optionally repeating a header), and
|
||||
** "Page n of m" footers. Characters the bundled font cannot encode print as ?.
|
||||
*******************************************************************************/
|
||||
final class PrintingLayout implements AutoCloseable
|
||||
{
|
||||
static final float MARGIN = 40;
|
||||
static final int MAX_PAGES = 500;
|
||||
|
||||
private final PDDocument document = new PDDocument();
|
||||
private final PDType0Font font;
|
||||
private final PDRectangle size;
|
||||
private final String title;
|
||||
private PDPageContentStream content;
|
||||
private float y;
|
||||
private List<String> repeatHeader = List.of();
|
||||
private int repeatSize = 8;
|
||||
private PrintingChrome chrome;
|
||||
private float leading = 1.35f;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Page size LETTER or A4, portrait or landscape; the first page carries
|
||||
** the title and subtitle.
|
||||
***************************************************************************/
|
||||
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)
|
||||
{
|
||||
throw new IOException("Bundled document font is missing");
|
||||
}
|
||||
font = PDType0Font.load(document, stream, true);
|
||||
PDRectangle base = "A4".equals(pageSize == null ? null : pageSize.toUpperCase(Locale.ROOT)) ? PDRectangle.A4 : PDRectangle.LETTER;
|
||||
size = landscape ? new PDRectangle(base.getHeight(), base.getWidth()) : base;
|
||||
this.title = title == null ? "" : title;
|
||||
newPage();
|
||||
line(this.title, 15);
|
||||
y -= 4;
|
||||
if(subtitle != null && !subtitle.isBlank())
|
||||
{
|
||||
line(subtitle, 9);
|
||||
}
|
||||
y -= 4;
|
||||
rule();
|
||||
y -= 6;
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
close();
|
||||
throw new QException("Could not start the print document", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many monospace characters fit across the page at a font size.
|
||||
***************************************************************************/
|
||||
int columns(float fontSize)
|
||||
{
|
||||
return (Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Line spacing as a multiple of the font size (1.35 by default; text
|
||||
** captures use less so box-drawing characters join up).
|
||||
***************************************************************************/
|
||||
void leading(float factor)
|
||||
{
|
||||
leading = factor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A note above the footer of every page (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
void footer(String text)
|
||||
{
|
||||
chrome = chrome.withFooterNote(text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Lines repeated at the top of every following page (a table header).
|
||||
***************************************************************************/
|
||||
void repeatOnNewPages(List<String> lines, int fontSize)
|
||||
{
|
||||
repeatHeader = List.copyOf(lines);
|
||||
repeatSize = fontSize;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line at a font size; breaks the page first when it does not fit.
|
||||
***************************************************************************/
|
||||
void line(String text, float fontSize) throws IOException
|
||||
{
|
||||
float step = fontSize * leading;
|
||||
if(y - step < bottom())
|
||||
{
|
||||
newPage();
|
||||
for(String header : repeatHeader)
|
||||
{
|
||||
draw(header, repeatSize, repeatSize * leading);
|
||||
}
|
||||
}
|
||||
draw(text, fontSize, step);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keep at least this many lines together (a heading with its first rows).
|
||||
***************************************************************************/
|
||||
void keep(int lines, int fontSize) throws IOException
|
||||
{
|
||||
if(y - lines * fontSize * leading < bottom())
|
||||
{
|
||||
newPage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Vertical space.
|
||||
***************************************************************************/
|
||||
void gap(float points)
|
||||
{
|
||||
y -= points;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A thin rule across the page.
|
||||
***************************************************************************/
|
||||
void rule() throws IOException
|
||||
{
|
||||
content.moveTo(MARGIN, y);
|
||||
content.lineTo(size.getWidth() - MARGIN, y);
|
||||
content.stroke();
|
||||
y -= 4;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Footers on every page, then the PDF bytes.
|
||||
***************************************************************************/
|
||||
byte[] finish() throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
content.close();
|
||||
content = null;
|
||||
int total = document.getNumberOfPages();
|
||||
for(int i = 0; i < total; i++)
|
||||
{
|
||||
PDPage page = document.getPage(i);
|
||||
try(PDPageContentStream footer = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true))
|
||||
{
|
||||
String text = "Page " + (i + 1) + " of " + total;
|
||||
float base = MARGIN - 14;
|
||||
footer.setLineWidth(0.5f);
|
||||
footer.moveTo(MARGIN, base + (hasNote() ? 20 : 10));
|
||||
footer.lineTo(size.getWidth() - MARGIN, base + (hasNote() ? 20 : 10));
|
||||
footer.stroke();
|
||||
if(hasNote())
|
||||
{
|
||||
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();
|
||||
document.save(output);
|
||||
return (output.toByteArray());
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
throw new QException("Could not render the print document", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Release the document.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(content != null)
|
||||
{
|
||||
content.close();
|
||||
}
|
||||
document.close();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
// nothing more to release
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Start a page; later pages show the running title.
|
||||
***************************************************************************/
|
||||
private void newPage() throws IOException
|
||||
{
|
||||
if(document.getNumberOfPages() >= MAX_PAGES)
|
||||
{
|
||||
throw new IOException("The document would exceed " + MAX_PAGES + " pages; print fewer rows");
|
||||
}
|
||||
if(content != null)
|
||||
{
|
||||
content.close();
|
||||
}
|
||||
PDPage page = new PDPage(size);
|
||||
document.addPage(page);
|
||||
content = new PDPageContentStream(document, page);
|
||||
y = size.getHeight() - MARGIN + 14;
|
||||
header();
|
||||
if(document.getNumberOfPages() > 1)
|
||||
{
|
||||
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
|
||||
y -= 4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
private void draw(String text, float fontSize, float step) throws IOException
|
||||
{
|
||||
y -= step;
|
||||
content.beginText();
|
||||
content.setFont(font, fontSize);
|
||||
content.newLineAtOffset(MARGIN, y);
|
||||
content.showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
|
||||
content.endText();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Replace characters the font cannot encode, and control characters.
|
||||
***************************************************************************/
|
||||
private String safe(String text)
|
||||
{
|
||||
StringBuilder out = new StringBuilder();
|
||||
text.codePoints().forEach(codePoint ->
|
||||
{
|
||||
String character = new String(Character.toChars(codePoint));
|
||||
if(Character.isISOControl(codePoint))
|
||||
{
|
||||
out.append(' ');
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
font.encode(character);
|
||||
out.append(character);
|
||||
}
|
||||
catch(IllegalArgumentException | IOException exception)
|
||||
{
|
||||
out.append('?');
|
||||
}
|
||||
});
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Width-limited columns for a table: natural widths shrunk to the line.
|
||||
***************************************************************************/
|
||||
static int[] columnWidths(List<String> headers, List<List<String>> rows, int available)
|
||||
{
|
||||
return (fit(naturalWidths(headers, rows), available));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Each column's natural width: the header or the 90th percentile of its
|
||||
** cells, between 4 and 60 characters.
|
||||
***************************************************************************/
|
||||
static int[] naturalWidths(List<String> headers, List<List<String>> rows)
|
||||
{
|
||||
int count = headers.size();
|
||||
int[] widths = new int[count];
|
||||
for(int c = 0; c < count; c++)
|
||||
{
|
||||
List<Integer> lengths = new ArrayList<>();
|
||||
for(List<String> row : rows)
|
||||
{
|
||||
lengths.add(c < row.size() && row.get(c) != null ? row.get(c).length() : 0);
|
||||
}
|
||||
lengths.sort(null);
|
||||
int typical = lengths.isEmpty() ? 0 : lengths.get(Math.max(0, (int) Math.ceil(lengths.size() * 0.9) - 1));
|
||||
widths[c] = Math.max(4, Math.min(60, Math.max(headers.get(c).length(), typical)));
|
||||
}
|
||||
return (widths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The width a row of these columns takes, with two-space gaps.
|
||||
***************************************************************************/
|
||||
static int lineWidth(int[] widths)
|
||||
{
|
||||
return (sum(widths) + Math.max(0, widths.length - 1) * 2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shrink widths (widest first, never below 4) until a row fits.
|
||||
***************************************************************************/
|
||||
static int[] fit(int[] start, int available)
|
||||
{
|
||||
int[] widths = start.clone();
|
||||
while(lineWidth(widths) > available)
|
||||
{
|
||||
int widest = 0;
|
||||
for(int c = 1; c < widths.length; c++)
|
||||
{
|
||||
if(widths[c] > widths[widest])
|
||||
{
|
||||
widest = c;
|
||||
}
|
||||
}
|
||||
if(widths[widest] <= 4)
|
||||
{
|
||||
break;
|
||||
}
|
||||
widths[widest]--;
|
||||
}
|
||||
return (widths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Sum of widths.
|
||||
***************************************************************************/
|
||||
private static int sum(int[] values)
|
||||
{
|
||||
int total = 0;
|
||||
for(int value : values)
|
||||
{
|
||||
total += value;
|
||||
}
|
||||
return (total);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One table row laid out in fixed-width columns.
|
||||
***************************************************************************/
|
||||
static String row(List<String> cells, int[] widths)
|
||||
{
|
||||
StringBuilder out = new StringBuilder();
|
||||
for(int c = 0; c < widths.length; c++)
|
||||
{
|
||||
String cell = c < cells.size() && cells.get(c) != null ? cells.get(c).replace('\n', ' ').replace('\r', ' ') : "";
|
||||
cell = PrintingRecordDocuments.truncate(cell, widths[c]);
|
||||
out.append(c == 0 ? "" : " ").append(String.format("%-" + widths[c] + "s", cell));
|
||||
}
|
||||
return (out.toString().stripTrailing());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* 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.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
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.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
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.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.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two print processes that are not bound to one table:
|
||||
**
|
||||
** - printingPrintTableList: a table list (grid) of any readable table. Values:
|
||||
** tableName, columns (comma separated, in order; default the visible
|
||||
** fields), filterJSON (a QQueryFilter with orderBys; its skip/limit are the
|
||||
** page), scope PAGE (the filter's skip/limit) or ALL (the whole result,
|
||||
** read a thousand rows at a time, up to maxRows: default and most 10,000;
|
||||
** a larger result prints its first rows and says so), columnWidths (the
|
||||
** grid's widths in characters, comma separated, same order), pageSize
|
||||
** LETTER/A4, orientation PORTRAIT/LANDSCAPE/AUTO (landscape when the
|
||||
** columns need it; the default), title, filterDescription (the filter as
|
||||
** the user sees it), then the printer and copies.
|
||||
** - printingPrintText: any text as a monospace PDF (a capture of a terminal
|
||||
** screen). Values: title, text, subtitle, pageSize, orientation, printer,
|
||||
** copies.
|
||||
**
|
||||
** Both ask for the printer on an options form (prefilled from the init
|
||||
** values), queue one PDF job and return its id in printingJobIds. Reading the
|
||||
** table enforces the user's table and record permissions.
|
||||
*******************************************************************************/
|
||||
public final class PrintingListProcesses
|
||||
{
|
||||
public static final String TABLE_LIST = "printingPrintTableList";
|
||||
public static final String TEXT = "printingPrintText";
|
||||
public static final int MAX_ROWS = 10_000;
|
||||
public static final int DEFAULT_MAX_ROWS = MAX_ROWS;
|
||||
static final int QUERY_PAGE = 1000;
|
||||
static final String REQUEST_ID = "printingListRequestId";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingListProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register both processes and their choice lists.
|
||||
***************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
choices(instance, "printing_pageSize", List.of("LETTER", "A4"), List.of("Letter", "A4"));
|
||||
choices(instance, "printing_orientation", List.of("AUTO", "PORTRAIT", "LANDSCAPE"), List.of("Automatic (landscape when the columns need it)", "Portrait", "Landscape"));
|
||||
choices(instance, "printing_listScope", List.of("ALL", "PAGE"), List.of("All rows (current filter and sort)", "The current page"));
|
||||
instance.addProcess(process(TABLE_LIST, "Print a table list", List.of(
|
||||
new QFieldMetaData("tableName", QFieldType.STRING).withLabel("Table").withIsRequired(true),
|
||||
new QFieldMetaData("scope", QFieldType.STRING).withLabel("Rows").withPossibleValueSourceName("printing_listScope").withDefaultValue("ALL"),
|
||||
new QFieldMetaData("maxRows", QFieldType.INTEGER).withLabel("Most rows (all rows)").withDefaultValue(DEFAULT_MAX_ROWS),
|
||||
new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("AUTO"))));
|
||||
instance.addProcess(process(TEXT, "Print text", List.of(
|
||||
new QFieldMetaData("title", QFieldType.STRING).withLabel("Title"),
|
||||
new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("PORTRAIT"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** prepare, options (printer and layout), execute, result.
|
||||
***************************************************************************/
|
||||
private static QProcessMetaData process(String name, String label, List<QFieldMetaData> extra)
|
||||
{
|
||||
List<QFieldMetaData> fields = new ArrayList<>(extra);
|
||||
fields.add(PrintingRecordProcesses.printerField());
|
||||
fields.add(new QFieldMetaData(PrintingRecordProcesses.COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1));
|
||||
fields.add(new QFieldMetaData("pageSize", QFieldType.STRING).withLabel("Paper").withPossibleValueSourceName("printing_pageSize").withDefaultValue("LETTER"));
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("options").withLabel("Choose a printer").withFormFields(fields)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
|
||||
.withViewFields(List.of(new QFieldMetaData("result", QFieldType.STRING).withLabel("Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A fixed enum choice list.
|
||||
***************************************************************************/
|
||||
private static void choices(QInstance instance, String name, List<String> ids, List<String> labels)
|
||||
{
|
||||
ArrayList<QPossibleValue<?>> values = new ArrayList<>();
|
||||
for(int i = 0; i < ids.size(); i++)
|
||||
{
|
||||
values.add(new QPossibleValue<>(ids.get(i), labels.get(i)));
|
||||
}
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The table a table list reads, checked readable.
|
||||
***************************************************************************/
|
||||
static QTableMetaData table(RunBackendStepInput input) throws QException
|
||||
{
|
||||
String name = input.getValueString("tableName");
|
||||
QTableMetaData table = name == null ? null : QContext.getQInstance().getTable(name);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Choose a table to print (unknown table " + name + ")");
|
||||
}
|
||||
PrintingTableSettings settings = PrintingTableSettings.of(table);
|
||||
if(settings != null && !settings.getPrintable())
|
||||
{
|
||||
throw new QException(table.getLabel() + " is not printable");
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The filter from filterJSON (or everything), with the page or the row limit.
|
||||
***************************************************************************/
|
||||
static QQueryFilter filter(RunBackendStepInput input) throws QException
|
||||
{
|
||||
String json = input.getValueString("filterJSON");
|
||||
QQueryFilter filter;
|
||||
try
|
||||
{
|
||||
filter = json == null || json.isBlank() ? new QQueryFilter() : JsonUtils.toObject(json, QQueryFilter.class);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
throw new QException("The filter is not valid: " + exception.getMessage(), exception);
|
||||
}
|
||||
if("ALL".equalsIgnoreCase(input.getValueString("scope")))
|
||||
{
|
||||
Integer max = input.getValueInteger("maxRows");
|
||||
int limit = max == null ? DEFAULT_MAX_ROWS : max;
|
||||
if(limit < 1 || limit > MAX_ROWS)
|
||||
{
|
||||
throw new QException("Most rows must be between 1 and " + MAX_ROWS);
|
||||
}
|
||||
filter.setSkip(0);
|
||||
filter.setLimit(limit);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(filter.getLimit() == null || filter.getLimit() > MAX_ROWS)
|
||||
{
|
||||
filter.setLimit(filter.getLimit() == null ? 50 : MAX_ROWS);
|
||||
}
|
||||
}
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The columns to print: given (unknown names refused), else visible fields.
|
||||
***************************************************************************/
|
||||
static List<QFieldMetaData> columns(QTableMetaData table, String given) throws QException
|
||||
{
|
||||
List<QFieldMetaData> out = new ArrayList<>();
|
||||
if(given == null || given.isBlank())
|
||||
{
|
||||
table.getFields().values().stream().filter(PrintingRecordProcesses::printable).forEach(out::add);
|
||||
return (out);
|
||||
}
|
||||
for(String name : Arrays.stream(given.split(",")).map(String::trim).filter(n -> !n.isEmpty()).toList())
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
if(field == null)
|
||||
{
|
||||
throw new QException("Table " + table.getName() + " has no field " + name);
|
||||
}
|
||||
if(PrintingRecordProcesses.printable(field))
|
||||
{
|
||||
out.add(field);
|
||||
}
|
||||
}
|
||||
if(out.isEmpty())
|
||||
{
|
||||
throw new QException("None of the columns can be printed");
|
||||
}
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The rows to print: the page as given, or the whole result read a page at
|
||||
** a time up to the filter's limit (the print limit).
|
||||
***************************************************************************/
|
||||
static List<QRecord> read(QTableMetaData table, QQueryFilter filter, boolean all) throws QException
|
||||
{
|
||||
List<QRecord> out = new ArrayList<>();
|
||||
int limit = filter.getLimit() == null ? 50 : filter.getLimit();
|
||||
int skip = filter.getSkip() == null ? 0 : filter.getSkip();
|
||||
while(out.size() < limit)
|
||||
{
|
||||
int size = all ? Math.min(QUERY_PAGE, limit - out.size()) : limit;
|
||||
QueryInput query = new QueryInput();
|
||||
query.setTableName(table.getName());
|
||||
query.setFilter(filter.clone().withSkip(skip + out.size()).withLimit(size));
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
query.setShouldGenerateDisplayValues(true);
|
||||
query.setShouldTranslatePossibleValues(true);
|
||||
List<QRecord> page = new QueryAction().execute(query).getRecords();
|
||||
out.addAll(page);
|
||||
if(!all || page.size() < size)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The sort as labels: "Last name, First name descending".
|
||||
***************************************************************************/
|
||||
static String sortText(QTableMetaData table, QQueryFilter filter)
|
||||
{
|
||||
if(filter.getOrderBys() == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for(var orderBy : filter.getOrderBys())
|
||||
{
|
||||
String name = orderBy.getFieldName();
|
||||
QFieldMetaData field = name == null ? null : table.getFields().get(name);
|
||||
parts.add((field == null || field.getLabel() == null ? name : field.getLabel()) + (orderBy.getIsAscending() ? "" : " descending"));
|
||||
}
|
||||
return (String.join(", ", parts));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The grid's column widths ("12,30,8"), or null.
|
||||
***************************************************************************/
|
||||
static int[] widths(String text)
|
||||
{
|
||||
if(text == null || text.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray());
|
||||
}
|
||||
catch(NumberFormatException exception)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** AUTO unless PORTRAIT or LANDSCAPE was chosen.
|
||||
***************************************************************************/
|
||||
static String orientation(RunBackendStepInput input)
|
||||
{
|
||||
String value = input.getValueString("orientation");
|
||||
return (value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Check access early so the form is not shown to someone who may not print.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
PrintingProcesses.currentService();
|
||||
String request = input.getValueString(REQUEST_ID);
|
||||
output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
|
||||
if(TABLE_LIST.equals(input.getProcessName()))
|
||||
{
|
||||
QTableMetaData table = table(input);
|
||||
CountInput count = new CountInput();
|
||||
count.setTableName(table.getName());
|
||||
count.setFilter(filter(input).clone().withSkip(null).withLimit(null));
|
||||
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
output.addValue("printingRowCount", new CountAction().execute(count).getCount());
|
||||
if(input.getValueString("title") == null)
|
||||
{
|
||||
output.addValue("title", table.getLabel() == null ? table.getName() : table.getLabel());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render and queue.
|
||||
***************************************************************************/
|
||||
public static class Execute implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** One PDF job on the chosen printer.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
QRecord printer = PrintingRecordProcesses.printer(input);
|
||||
int copies = PrintingRecordProcesses.copies(input);
|
||||
String request = input.getValueString(REQUEST_ID);
|
||||
if(request == null || request.isBlank())
|
||||
{
|
||||
throw new QException("Restart the print to obtain a safe request identity");
|
||||
}
|
||||
Rendered rendered = render(input, TABLE_LIST.equals(input.getProcessName()));
|
||||
if(rendered.message() != null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,24 @@ public class PrintingProcesses implements BackendStep
|
||||
currentService();
|
||||
String request = input.getValueString(TEST_REQUEST_ID);
|
||||
output.addValue(TEST_REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
|
||||
keepSelection(input, output);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A selection action whose first step is a form loses the init request's
|
||||
** record selection: the UI API passes it only to the init call (as the
|
||||
** callback filter) and the extract step, which would store it, runs after
|
||||
** the form. Keep it as the extract step's own queryFilterJson value.
|
||||
***************************************************************************/
|
||||
static void keepSelection(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
String kept = input.getValueString("queryFilterJson");
|
||||
if((kept == null || kept.isBlank()) && input.getCallback() != null && input.getCallback().getQueryFilter() != null)
|
||||
{
|
||||
output.addValue("queryFilterJson", com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(input.getCallback().getQueryFilter()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A Letter "record sheet": a heading and subtitle, then each section of the
|
||||
** record (label/value lines, wrapped and continued across pages), then a
|
||||
** summary of each child list (its label, how many rows, and the first rows as
|
||||
** a small table). It is the generic document a record print renders when the
|
||||
** host supplies no template of its own.
|
||||
*******************************************************************************/
|
||||
public final class PrintingRecordDocuments
|
||||
{
|
||||
static final int MAX_LINES = 4000;
|
||||
private static final int BODY_SIZE = 10;
|
||||
private static final int CHILD_SIZE = 8;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingRecordDocuments()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One section: a label (blank for none) and label/value lines in order.
|
||||
***************************************************************************/
|
||||
public record Section(String label, Map<String, String> fields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One child list: its label, total row count, column headings and the
|
||||
** first rows (display text).
|
||||
***************************************************************************/
|
||||
public record ChildList(String label, int total, List<String> columns, List<List<String>> rows)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One section without children (the original record sheet).
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, List.of(new Section("", fields)), List.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render sections and child list summaries as a PDF.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, sections, children, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render sections and child list summaries under the app's header/footer.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, PrintingChrome chrome) throws QException
|
||||
{
|
||||
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome))
|
||||
{
|
||||
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));
|
||||
int lines = 0;
|
||||
for(Section section : sections)
|
||||
{
|
||||
if(section.fields().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(section.label() != null && !section.label().isBlank())
|
||||
{
|
||||
layout.gap(4);
|
||||
layout.keep(3, BODY_SIZE);
|
||||
layout.line(section.label(), BODY_SIZE + 1);
|
||||
}
|
||||
for(Map.Entry<String, String> field : section.fields().entrySet())
|
||||
{
|
||||
List<String> wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1);
|
||||
for(int i = 0; i < wrapped.size() && lines++ < MAX_LINES; i++)
|
||||
{
|
||||
layout.line(String.format("%-" + labelWidth + "s %s", i == 0 ? truncate(field.getKey(), labelWidth - 1) : "", wrapped.get(i)), BODY_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
for(ChildList child : children)
|
||||
{
|
||||
layout.gap(8);
|
||||
layout.keep(4, CHILD_SIZE);
|
||||
layout.line(child.label() + " (" + child.total() + ")", BODY_SIZE + 1);
|
||||
if(child.rows().isEmpty())
|
||||
{
|
||||
layout.line("(none)", CHILD_SIZE);
|
||||
continue;
|
||||
}
|
||||
int[] widths = PrintingLayout.columnWidths(child.columns(), child.rows(), layout.columns(CHILD_SIZE));
|
||||
layout.line(PrintingLayout.row(child.columns(), widths), CHILD_SIZE);
|
||||
for(List<String> row : child.rows())
|
||||
{
|
||||
layout.line(PrintingLayout.row(row, widths), CHILD_SIZE);
|
||||
}
|
||||
if(child.total() > child.rows().size())
|
||||
{
|
||||
layout.line("... and " + (child.total() - child.rows().size()) + " more", CHILD_SIZE);
|
||||
}
|
||||
}
|
||||
return (layout.finish());
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
throw new QException("Could not render the record document: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Break text at spaces (or hard, for long words) into lines of at most
|
||||
** width characters; embedded line breaks start new lines.
|
||||
***************************************************************************/
|
||||
static List<String> wrap(String text, int width)
|
||||
{
|
||||
List<String> lines = new ArrayList<>();
|
||||
for(String paragraph : text.replace("\r\n", "\n").replace('\r', '\n').replace('\t', ' ').split("\n", -1))
|
||||
{
|
||||
String rest = paragraph.strip();
|
||||
if(rest.isEmpty())
|
||||
{
|
||||
lines.add("");
|
||||
continue;
|
||||
}
|
||||
while(rest.length() > width)
|
||||
{
|
||||
int cut = rest.lastIndexOf(' ', width);
|
||||
if(cut <= 0)
|
||||
{
|
||||
cut = width;
|
||||
}
|
||||
lines.add(rest.substring(0, cut).stripTrailing());
|
||||
rest = rest.substring(cut).stripLeading();
|
||||
}
|
||||
lines.add(rest);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shorten to at most length characters, marking the cut.
|
||||
***************************************************************************/
|
||||
static String truncate(String text, int length)
|
||||
{
|
||||
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ');
|
||||
return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* 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.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
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.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
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.processes.Status;
|
||||
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.QQueryFilter;
|
||||
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.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;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Print" on the records of a host table: choose an approved, exposed PDF
|
||||
** printer and a copy count, review the selection, then each selected record is
|
||||
** rendered as a record sheet (PrintingRecordDocuments) and queued as its own
|
||||
** print job through the ordinary durable queue. The queued job IDs are returned
|
||||
** in the process value printingJobIds (comma separated) so clients can follow
|
||||
** each job's status in Print jobs.
|
||||
**
|
||||
** Register per table with register(instance, tableName); the process is named
|
||||
** printingPrintRecords_{tableName} (processName(tableName)).
|
||||
*******************************************************************************/
|
||||
public final class PrintingRecordProcesses
|
||||
{
|
||||
public static final String PREFIX = "printingPrintRecords_";
|
||||
public static final String PRINTER_FIELD = "printingPrinterId";
|
||||
public static final String COPIES_FIELD = "printingCopies";
|
||||
public static final String JOB_IDS = "printingJobIds";
|
||||
static final String REQUEST_ID = "printingRecordRequestId";
|
||||
static final int CHILD_ROWS = 10;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingRecordProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The record-print process name of a table.
|
||||
***************************************************************************/
|
||||
public static String processName(String tableName)
|
||||
{
|
||||
return (PREFIX + tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Add the Print action to a table's records and selections.
|
||||
***************************************************************************/
|
||||
public static void register(QInstance instance, String tableName) throws QException
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Cannot add printing to unknown table " + tableName);
|
||||
}
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, tableName);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, tableName);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "Each selected record will be printed as its own job on the chosen printer after you continue.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
|
||||
.withName(processName(tableName)).withLabel("Print").withTableName(tableName).withMinInputRecords(1)
|
||||
.withPermissionRules(PrintingProcesses.permissionRules());
|
||||
process.withStep(0, new QFrontendStepMetaData().withName("printer").withLabel("Choose a printer")
|
||||
.withFormFields(List.of(printerField(), new QFieldMetaData(COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1)))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
process.withStep(0, new QBackendStepMetaData().withName("preparePrint").withCode(new QCodeReference(Prepare.class)));
|
||||
instance.addProcess(process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Add Print to every table of the instance that can be read one record at
|
||||
** a time: skips hidden tables, tables without a primary key or without
|
||||
** TABLE_GET, tables that already have Print, and tables whose
|
||||
** PrintingTableSettings say printable false. Call it after every table is
|
||||
** in the instance. Returns the tables that got Print.
|
||||
***************************************************************************/
|
||||
public static List<String> registerAll(QInstance instance) throws QException
|
||||
{
|
||||
List<String> added = new ArrayList<>();
|
||||
for(QTableMetaData table : new ArrayList<>(instance.getTables().values()))
|
||||
{
|
||||
PrintingTableSettings settings = PrintingTableSettings.of(table);
|
||||
boolean readable = table.isCapabilityEnabled(instance.getBackendForTable(table.getName()), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_GET);
|
||||
if(Boolean.TRUE.equals(table.getIsHidden()) || table.getPrimaryKeyField() == null || !readable
|
||||
|| (settings != null && !settings.getPrintable()) || instance.getProcess(processName(table.getName())) != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
register(instance, table.getName());
|
||||
added.add(table.getName());
|
||||
}
|
||||
return (added);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only approved printers that the device currently exposes and that take PDF.
|
||||
***************************************************************************/
|
||||
static QFieldMetaData printerField()
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The chosen printer, re-read with the caller's permissions and checked.
|
||||
***************************************************************************/
|
||||
static QRecord printer(RunBackendStepInput input) throws QException
|
||||
{
|
||||
String id = input.getValueString(PRINTER_FIELD);
|
||||
if(id == null || id.isBlank())
|
||||
{
|
||||
throw new QException("Choose a printer");
|
||||
}
|
||||
QRecord printer = PrintingProcesses.resolvePrinter(id, null);
|
||||
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|
||||
|| printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF"))
|
||||
{
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
|
||||
}
|
||||
return (printer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 1 when unset; 1 to 100 otherwise (the protocol's copies range).
|
||||
***************************************************************************/
|
||||
static int copies(RunBackendStepInput input) throws QException
|
||||
{
|
||||
Integer copies = input.getValueInteger(COPIES_FIELD);
|
||||
if(copies == null)
|
||||
{
|
||||
return (1);
|
||||
}
|
||||
if(copies < 1 || copies > 100)
|
||||
{
|
||||
throw new QException("Copies must be between 1 and 100");
|
||||
}
|
||||
return (copies);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render one record (display values, visible non-binary fields) and queue it.
|
||||
***************************************************************************/
|
||||
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, PrintingChrome chrome) throws QException
|
||||
{
|
||||
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());
|
||||
get.setPrimaryKey(primaryKey);
|
||||
get.setShouldGenerateDisplayValues(true);
|
||||
get.setShouldTranslatePossibleValues(true);
|
||||
QRecord record = new GetAction().execute(get).getRecord();
|
||||
if(record == null)
|
||||
{
|
||||
throw new QException("Record " + primaryKey + " of " + table.getLabel() + " is not accessible");
|
||||
}
|
||||
String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey);
|
||||
String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel();
|
||||
String subtitle = tableLabel + " " + primaryKey;
|
||||
PrintingChrome pageChrome = chrome == null ? PrintingChrome.defaults(tableLabel + " › " + label) : chrome.withTitleIfBlank(tableLabel + " › " + label);
|
||||
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record), pageChrome);
|
||||
return (new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** "printed <instant> by <user>" for subtitles.
|
||||
***************************************************************************/
|
||||
static String printedBy()
|
||||
{
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName();
|
||||
return ("printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Display text of one field of a record (display value, else raw value).
|
||||
***************************************************************************/
|
||||
static String text(QRecord record, QFieldMetaData field)
|
||||
{
|
||||
String display = record.getDisplayValue(field.getName());
|
||||
Serializable raw = record.getValue(field.getName());
|
||||
return (display != null ? display : raw == null ? null : String.valueOf(raw));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a field belongs on paper: visible and not binary.
|
||||
***************************************************************************/
|
||||
static boolean printable(QFieldMetaData field)
|
||||
{
|
||||
return (field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The record's field sections in tier order (T1, T2, T3), as the record
|
||||
** view shows them; fields in no section follow under "Other fields". A
|
||||
** table without sections prints one unlabeled section of every field.
|
||||
***************************************************************************/
|
||||
static List<PrintingRecordDocuments.Section> sections(QTableMetaData table, QRecord record)
|
||||
{
|
||||
List<PrintingRecordDocuments.Section> out = new ArrayList<>();
|
||||
java.util.Set<String> placed = new java.util.HashSet<>();
|
||||
List<com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
|
||||
sections.removeIf(section -> Boolean.TRUE.equals(section.getIsHidden()) || section.getFieldNames() == null || section.getFieldNames().isEmpty());
|
||||
sections.sort(java.util.Comparator.comparing(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
|
||||
for(var section : sections)
|
||||
{
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for(String name : section.getFieldNames())
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
if(printable(field) && placed.add(name))
|
||||
{
|
||||
fields.put(field.getLabel() == null ? name : field.getLabel(), text(record, field));
|
||||
}
|
||||
}
|
||||
out.add(new PrintingRecordDocuments.Section(sections.size() == 1 ? "" : section.getLabel(), fields));
|
||||
}
|
||||
Map<String, String> rest = new LinkedHashMap<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(printable(field) && !placed.contains(field.getName()))
|
||||
{
|
||||
rest.put(field.getLabel() == null ? field.getName() : field.getLabel(), text(record, field));
|
||||
}
|
||||
}
|
||||
if(!rest.isEmpty())
|
||||
{
|
||||
out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest));
|
||||
}
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Each association of the table: its label, how many child rows, and the
|
||||
** first rows with up to five visible columns. Children the user may not
|
||||
** read, or that fail to load, are left out rather than failing the print.
|
||||
***************************************************************************/
|
||||
static List<PrintingRecordDocuments.ChildList> children(QTableMetaData table, QRecord record)
|
||||
{
|
||||
List<PrintingRecordDocuments.ChildList> out = new ArrayList<>();
|
||||
for(var association : table.getAssociations() == null ? List.<com.kingsrook.qqq.backend.core.model.metadata.tables.Association>of() : table.getAssociations())
|
||||
{
|
||||
try
|
||||
{
|
||||
var join = QContext.getQInstance().getJoin(association.getJoinName());
|
||||
QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
if(join == null || child == null || join.getJoinOns() == null || join.getJoinOns().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean forward = table.getName().equals(join.getLeftTable());
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
for(var on : join.getJoinOns())
|
||||
{
|
||||
Serializable value = record.getValue(forward ? on.getLeftField() : on.getRightField());
|
||||
filter.withCriteria(new QFilterCriteria(forward ? on.getRightField() : on.getLeftField(), QCriteriaOperator.EQUALS, value));
|
||||
}
|
||||
var count = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput();
|
||||
count.setTableName(child.getName());
|
||||
count.setFilter(filter);
|
||||
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
Integer total = new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(count).getCount();
|
||||
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput();
|
||||
query.setTableName(child.getName());
|
||||
query.setFilter(filter.clone().withLimit(CHILD_ROWS));
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
query.setShouldGenerateDisplayValues(true);
|
||||
query.setShouldTranslatePossibleValues(true);
|
||||
List<QRecord> rows = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords();
|
||||
java.util.Set<String> joinFields = new java.util.HashSet<>();
|
||||
join.getJoinOns().forEach(on -> joinFields.add(forward ? on.getRightField() : on.getLeftField()));
|
||||
List<QFieldMetaData> columns = child.getFields().values().stream()
|
||||
.filter(field -> printable(field) && !joinFields.contains(field.getName())).limit(5).toList();
|
||||
List<List<String>> cells = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
List<String> line = new ArrayList<>();
|
||||
columns.forEach(field -> line.add(text(row, field)));
|
||||
cells.add(line);
|
||||
}
|
||||
String label = association.getName().equals(child.getName()) && child.getLabel() != null ? child.getLabel() : com.kingsrook.qqq.backend.core.instances.QInstanceEnricher.nameToLabel(association.getName());
|
||||
out.add(new PrintingRecordDocuments.ChildList(label, total == null ? rows.size() : total,
|
||||
columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), cells));
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
// a child list the user may not read (or that cannot load) is left out of the sheet
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Queue one PDF on a printer through the durable queue; returns the job id.
|
||||
***************************************************************************/
|
||||
static String queue(QRecord printer, String title, byte[] bytes, int copies, String idempotencyKey) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
JSONObject job = PrintingProcesses.currentService().submit(new JSONObject()
|
||||
.put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
|
||||
.put("title", PrintingRecordDocuments.truncate(title, 200)).put("format", "PDF")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", idempotencyKey)
|
||||
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
|
||||
return (job.getString("jobId"));
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
throw new QException("Could not queue the print job: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One request identity per process run, so a replayed load does not print twice.
|
||||
***************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Require printing access before any form is shown.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
PrintingProcesses.currentService();
|
||||
String request = input.getValueString(REQUEST_ID);
|
||||
output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
|
||||
PrintingProcesses.keepSelection(input, output);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Review: the printer is still usable; the selected records pass through.
|
||||
***************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null)
|
||||
.withSingularFutureMessage("record will be printed").withPluralFutureMessage("records will be printed");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Check the printer and copies once per page; keep the records.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
printer(input);
|
||||
copies(input);
|
||||
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
ready.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard review summary.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute: render and queue each record; report the job IDs.
|
||||
***************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine done = new ProcessSummaryLine(Status.OK, 0, null)
|
||||
.withSingularPastMessage("record was sent to the printer").withPluralPastMessage("records were sent to the printer");
|
||||
private final List<String> jobIds = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the printer again (access may have changed since review).
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
QRecord printer = printer(input);
|
||||
int copies = copies(input);
|
||||
String request = input.getValueString(REQUEST_ID);
|
||||
if(request == null || request.isBlank())
|
||||
{
|
||||
throw new QException("Restart Print to obtain a safe request identity");
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
jobIds.add(submit(table, record, printer, copies, request, PrintingChrome.from(input, null)));
|
||||
done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
output.addValue(JOB_IDS, String.join(",", jobIds));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Result summary: what was sent, and the job IDs to follow.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>(List.of(done));
|
||||
if(!jobIds.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.IOException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table list: a grid of rows under a repeated column header, in fixed-width
|
||||
** monospace columns sized from the content and shrunk to fit the page.
|
||||
*******************************************************************************/
|
||||
public final class PrintingTableDocuments
|
||||
{
|
||||
static final int FONT_SIZE = 8;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingTableDocuments()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render columns and rows (display text) on LETTER or A4, portrait or
|
||||
** landscape, without preferred widths or a footer.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render a table list. Preferred widths (the grid's, in characters) are
|
||||
** the starting column widths, else the content decides; either way they
|
||||
** shrink to fit the page. Orientation AUTO turns the page landscape when
|
||||
** the columns do not fit portrait. The header repeats on every page; the
|
||||
** footer text goes at the bottom left of every page.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation, PrintingChrome chrome) throws QException
|
||||
{
|
||||
if(columns.isEmpty())
|
||||
{
|
||||
throw new QException("A table list needs at least one column");
|
||||
}
|
||||
int[] natural = PrintingLayout.naturalWidths(columns, rows);
|
||||
if(preferredWidths != null && preferredWidths.length == columns.size())
|
||||
{
|
||||
for(int c = 0; c < natural.length; c++)
|
||||
{
|
||||
natural[c] = Math.max(4, Math.min(60, preferredWidths[c]));
|
||||
}
|
||||
}
|
||||
boolean landscape = isLandscape(orientation, natural, pageSize);
|
||||
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome))
|
||||
{
|
||||
layout.footer(footer);
|
||||
int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE));
|
||||
String header = PrintingLayout.row(columns, widths);
|
||||
String rule = "-".repeat(Math.min(layout.columns(FONT_SIZE), Math.max(1, header.length())));
|
||||
layout.line(header, FONT_SIZE);
|
||||
layout.line(rule, FONT_SIZE);
|
||||
layout.repeatOnNewPages(List.of(header, rule), FONT_SIZE);
|
||||
for(List<String> row : rows)
|
||||
{
|
||||
layout.line(PrintingLayout.row(row, widths), FONT_SIZE);
|
||||
}
|
||||
layout.repeatOnNewPages(List.of(), FONT_SIZE);
|
||||
layout.gap(4);
|
||||
layout.line(rows.size() + (rows.size() == 1 ? " row" : " rows"), FONT_SIZE);
|
||||
return (layout.finish());
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
throw new QException("Could not render the table list: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** PORTRAIT, LANDSCAPE, or AUTO: landscape when the natural widths do not
|
||||
** fit across a portrait page.
|
||||
***************************************************************************/
|
||||
static boolean isLandscape(String orientation, int[] natural, String pageSize)
|
||||
{
|
||||
if("LANDSCAPE".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!"AUTO".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
float width = ("A4".equalsIgnoreCase(pageSize) ? 595.28f : 612f) - 2 * PrintingLayout.MARGIN;
|
||||
return (PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-table printing metadata. A table carrying it with printable false gets
|
||||
** no Print process from PrintingRecordProcesses.registerAll:
|
||||
** {@code table.withSupplementalMetaData(new PrintingTableSettings().withPrintable(false))}.
|
||||
*******************************************************************************/
|
||||
public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
{
|
||||
public static final String TYPE = "printing";
|
||||
|
||||
private boolean printable = true;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The settings of a table, or null when it has none.
|
||||
***************************************************************************/
|
||||
public static PrintingTableSettings of(QTableMetaData table)
|
||||
{
|
||||
return (table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Supplemental metadata type.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the table's records and lists may be printed (default true).
|
||||
***************************************************************************/
|
||||
public boolean getPrintable()
|
||||
{
|
||||
return (printable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fluent setter.
|
||||
***************************************************************************/
|
||||
public PrintingTableSettings withPrintable(boolean value)
|
||||
{
|
||||
printable = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** No mutable references beyond the flag, which the base clone copies.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.IOException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain text as a PDF: monospace, long lines hard-wrapped, broken across
|
||||
** pages. Used to print a capture of a terminal screen (or any text).
|
||||
*******************************************************************************/
|
||||
public final class PrintingTextDocuments
|
||||
{
|
||||
static final int FONT_SIZE = 8;
|
||||
static final float MIN_FONT_SIZE = 5.5f;
|
||||
static final int MAX_CHARACTERS = 2_000_000;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private 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())
|
||||
{
|
||||
throw new QException("There is no text to print");
|
||||
}
|
||||
if(text.length() > MAX_CHARACTERS)
|
||||
{
|
||||
throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)");
|
||||
}
|
||||
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome))
|
||||
{
|
||||
String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1);
|
||||
float size = fontSize(lines, layout.columns(1) * 1f);
|
||||
int width = layout.columns(size);
|
||||
layout.leading(1.2f);
|
||||
for(String line : lines)
|
||||
{
|
||||
String rest = line.stripTrailing();
|
||||
do
|
||||
{
|
||||
layout.line(rest.length() > width ? rest.substring(0, width) : rest, size);
|
||||
rest = rest.length() > width ? rest.substring(width) : "";
|
||||
}
|
||||
while(!rest.isEmpty());
|
||||
}
|
||||
return (layout.finish());
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
throw new QException("Could not render the text: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The font size (8 down to 5.5 points) at which the widest line fits, so a
|
||||
** screen capture keeps its layout; wider text still wraps at 5.5.
|
||||
***************************************************************************/
|
||||
static float fontSize(String[] lines, float charactersAtOnePoint)
|
||||
{
|
||||
int widest = 1;
|
||||
for(String line : lines)
|
||||
{
|
||||
widest = Math.max(widest, line.stripTrailing().length());
|
||||
}
|
||||
float fits = charactersAtOnePoint / widest;
|
||||
return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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. */
|
||||
/** 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.0.0")
|
||||
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1.")
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user