chore: integrate printing standards and strict Java compiler checks
Preserve current rendering, operator and retained-source behavior while integrating script quality, meaningful headers and strict Error Prone/NullAway contracts. Production and test compilation and the 189-case verification gate pass with zero failures and one intentional workload skip. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
132 files changed
+4618
-1716
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.git
|
||||
**/bin
|
||||
**/obj
|
||||
target
|
||||
.local
|
||||
.venv
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": "3.13",
|
||||
"include": [
|
||||
"scripts/*.py"
|
||||
],
|
||||
"reportMissingTypeStubs": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
"reportImplicitOverride": "error",
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "..",
|
||||
"venv": ".venv"
|
||||
}
|
||||
@@ -2,55 +2,107 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False)
|
||||
|
||||
|
||||
def check(directory, language, threshold):
|
||||
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
|
||||
raise ValueError("Invalid security severity threshold")
|
||||
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
|
||||
files = sorted(Path(directory).glob("*.sarif"))
|
||||
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
|
||||
raise ValueError("Missing expected CodeQL SARIF report for " + language)
|
||||
blocking = []
|
||||
for path in files:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs = report.get("runs")
|
||||
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
|
||||
raise ValueError("Invalid or empty SARIF runs: " + path.name)
|
||||
for run in runs:
|
||||
driver = run.get("tool", {}).get("driver", {})
|
||||
results = run.get("results")
|
||||
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
|
||||
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
|
||||
rules = {}
|
||||
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
|
||||
for rule in component.get("rules", []):
|
||||
rules[rule["id"]] = rule
|
||||
if not rules:
|
||||
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
|
||||
for invocation in run.get("invocations", []):
|
||||
if invocation.get("executionSuccessful") is False:
|
||||
raise ValueError("CodeQL invocation failed: " + path.name)
|
||||
for result in results:
|
||||
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
|
||||
if rule_id not in rules:
|
||||
raise ValueError("Result references missing rule metadata: " + path.name)
|
||||
if result.get("suppressions"):
|
||||
continue
|
||||
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
|
||||
if not math.isfinite(severity) or not 0 <= severity <= 10:
|
||||
raise ValueError("Invalid rule severity: " + rule_id)
|
||||
if severity >= threshold:
|
||||
blocking.append((severity, rule_id))
|
||||
for severity, rule_id in sorted(blocking, reverse=True):
|
||||
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
|
||||
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
|
||||
return bool(blocking)
|
||||
class Rule(TypedDict):
|
||||
"""Keep the rule identity and optional severity metadata used by the gate."""
|
||||
|
||||
id: str
|
||||
properties: RuleProperties
|
||||
|
||||
|
||||
class Component(TypedDict, total=False):
|
||||
"""Describe CodeQL driver or extension rule metadata."""
|
||||
|
||||
name: str
|
||||
rules: list[Rule]
|
||||
|
||||
|
||||
class Tool(TypedDict, total=False):
|
||||
"""Group the driver and optional extension rule catalogs."""
|
||||
|
||||
driver: Component
|
||||
extensions: list[Component]
|
||||
|
||||
|
||||
class Result(TypedDict, total=False):
|
||||
"""Describe only the result fields consumed by the severity gate."""
|
||||
|
||||
ruleId: str
|
||||
rule: dict[str, str]
|
||||
suppressions: list[object]
|
||||
|
||||
|
||||
class Run(TypedDict, total=False):
|
||||
"""Retain invocation success and findings for one analyzed language."""
|
||||
|
||||
tool: Tool
|
||||
results: list[Result]
|
||||
invocations: list[dict[str, bool]]
|
||||
|
||||
|
||||
class Report(TypedDict, total=False):
|
||||
"""Represent SARIF fields while retaining the explicit runtime validation."""
|
||||
|
||||
version: str
|
||||
runs: list[Run]
|
||||
|
||||
|
||||
MAX_SEVERITY = 10
|
||||
|
||||
|
||||
def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped.
|
||||
"""Validate complete CodeQL evidence and report findings at the threshold."""
|
||||
if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY:
|
||||
raise ValueError("Invalid security severity threshold")
|
||||
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
|
||||
files = sorted(Path(directory).glob("*.sarif"))
|
||||
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
|
||||
raise ValueError("Missing expected CodeQL SARIF report for " + language)
|
||||
blocking: list[tuple[float, str]] = []
|
||||
for path in files:
|
||||
report: Report = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs = report.get("runs")
|
||||
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
|
||||
raise ValueError("Invalid or empty SARIF runs: " + path.name)
|
||||
for run in runs:
|
||||
driver = run.get("tool", {}).get("driver", {})
|
||||
results = run.get("results")
|
||||
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
|
||||
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
|
||||
rules: dict[str, Rule] = {}
|
||||
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
|
||||
for rule in component.get("rules", []):
|
||||
rules[rule["id"]] = rule
|
||||
if not rules:
|
||||
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
|
||||
for invocation in run.get("invocations", []):
|
||||
if invocation.get("executionSuccessful") is False:
|
||||
raise ValueError("CodeQL invocation failed: " + path.name)
|
||||
for result in results:
|
||||
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
|
||||
if rule_id not in rules:
|
||||
raise ValueError("Result references missing rule metadata: " + path.name)
|
||||
if result.get("suppressions"):
|
||||
continue
|
||||
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
|
||||
if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY:
|
||||
raise ValueError("Invalid rule severity: " + rule_id)
|
||||
if severity >= threshold:
|
||||
blocking.append((severity, rule_id))
|
||||
for severity, rule_id in sorted(blocking, reverse=True):
|
||||
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
|
||||
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
|
||||
return bool(blocking)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
|
||||
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exercise the repository's actual compiler configuration on isolated probes."""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
NS = "{http://maven.apache.org/POM/4.0.0}"
|
||||
LOG = logging.getLogger(__name__)
|
||||
PROBES: dict[str, tuple[str, str | None]] = {
|
||||
"positive": ('public String value() { return "present"; }', None),
|
||||
"nullaway": ("public String value() { return null; }", "[NullAway]"),
|
||||
"errorprone": ("public void value() { new IllegalArgumentException(); }", "[DeadException]"),
|
||||
"warnings": ('public long value() { return java.util.Date.parse("2000-01-01"); }', "warnings found and -Werror specified"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardContext:
|
||||
"""Everything shared by all probes: repository root, evidence folder, probe POM and Maven command."""
|
||||
|
||||
root: Path
|
||||
output: Path
|
||||
pom: ET.ElementTree
|
||||
command: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Probe:
|
||||
"""One isolated compilation: source root, name, the member source and the diagnostic it must produce (None means it must compile)."""
|
||||
|
||||
phase: str
|
||||
name: str
|
||||
source: str
|
||||
expected: str | None
|
||||
|
||||
|
||||
def probe_pom(source: ET.Element) -> ET.ElementTree:
|
||||
"""Copy compiler configuration, including executions, without changing its flags."""
|
||||
compiler = source.find(f"{NS}build/{NS}plugins/{NS}plugin[{NS}artifactId='maven-compiler-plugin']")
|
||||
if compiler is None:
|
||||
raise ValueError("Repository must declare its compiler plugin")
|
||||
project = ET.Element(f"{NS}project")
|
||||
for name, value in (("modelVersion", "4.0.0"), ("groupId", "com.kingsrook.qbits"), ("artifactId", "compiler-configuration-probe"), ("version", "1")):
|
||||
ET.SubElement(project, f"{NS}{name}").text = value
|
||||
properties = source.find(f"{NS}properties")
|
||||
if properties is not None:
|
||||
project.append(copy.deepcopy(properties))
|
||||
plugins = ET.SubElement(ET.SubElement(project, f"{NS}build"), f"{NS}plugins")
|
||||
plugins.append(copy.deepcopy(compiler))
|
||||
return ET.ElementTree(project)
|
||||
|
||||
|
||||
def run_probe(context: GuardContext, probe: Probe) -> dict[str, str | bool]:
|
||||
"""Require the intended diagnostic, not merely an unrelated Maven failure."""
|
||||
folder = context.output / f"{probe.phase}-{probe.name}"
|
||||
package = folder / "src" / probe.phase / "java" / "com" / "kingsrook" / "qbits" / "printing"
|
||||
package.mkdir(parents=True)
|
||||
(package / "Probe.java").write_text(f"// Generated compiler guard fixture; not application source.\npackage com.kingsrook.qbits.printing;\npublic final class Probe {{ {probe.source} }}\n", encoding="utf-8")
|
||||
context.pom.write(folder / "pom.xml", encoding="utf-8", xml_declaration=True)
|
||||
result = subprocess.run( # noqa: S603 -- argument list (no shell) of the repository Maven wrapper or the operator-supplied executable plus fixed Maven goals
|
||||
[*context.command, "-f", str(folder / "pom.xml"), "test-compile"],
|
||||
cwd=context.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
log = result.stdout + result.stderr
|
||||
(folder / "compiler.log").write_text(log, encoding="utf-8")
|
||||
passed = result.returncode == 0 if probe.expected is None else result.returncode != 0 and probe.expected in log
|
||||
if probe.name == "warnings":
|
||||
passed = passed and "[deprecation]" in log
|
||||
if not passed:
|
||||
raise RuntimeError(f"Compiler guard failed: {probe.phase}/{probe.name}; inspect {folder / 'compiler.log'}")
|
||||
return {"sourceRoot": probe.phase, "probe": probe.name, "expectedRejection": probe.expected is not None, "passed": True}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run positive and negative main/test probes without installing any artifact."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--maven", help="Optional exact Maven executable; otherwise use the repository wrapper")
|
||||
parser.add_argument("--settings", type=Path)
|
||||
parser.add_argument("--local-repository", type=Path)
|
||||
parser.add_argument("--offline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
command = [args.maven] if args.maven else ([str(root / "mvnw.cmd")] if os.name == "nt" else ["bash", str(root / "mvnw")])
|
||||
command += ["-B", "-ntp"]
|
||||
if args.offline:
|
||||
command.append("-o")
|
||||
if args.settings:
|
||||
command += ["-s", str(args.settings.resolve())]
|
||||
if args.local_repository:
|
||||
command.append(f"-Dmaven.repo.local={args.local_repository.resolve()}")
|
||||
output_root = root / "target" / "compiler-guard"
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
output = Path(tempfile.mkdtemp(prefix="run-", dir=output_root))
|
||||
ET.register_namespace("", NS[1:-1])
|
||||
pom = probe_pom(ET.parse(root / "pom.xml").getroot()) # noqa: S314 -- parses this repository's own checked-in pom.xml, never external input
|
||||
context = GuardContext(root, output, pom, command)
|
||||
results: list[dict[str, str | bool]] = []
|
||||
for phase in ("main", "test"):
|
||||
for name, (source, expected) in PROBES.items():
|
||||
results.append(run_probe(context, Probe(phase, name, source, expected)))
|
||||
(output / "results.json").write_text(json.dumps(results, indent=3) + "\n", encoding="utf-8")
|
||||
LOG.info("Compiler guard passed", extra={"probeCount": len(results), "evidenceDirectory": str(output)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
main()
|
||||
@@ -2,127 +2,172 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Fail closed unless scanners cover Maven's selected UI-host runtime graph."""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, cast
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
|
||||
class Bom(TypedDict, total=False):
|
||||
"""Describe the selected CycloneDX coordinate fields."""
|
||||
|
||||
bomFormat: str
|
||||
components: list[dict[str, str]]
|
||||
metadata: dict[str, dict[str, str]]
|
||||
|
||||
|
||||
class TrivyReport(TypedDict, total=False):
|
||||
"""Describe package coordinates, leaving runtime shape checks intact."""
|
||||
|
||||
Results: list[dict[str, list[dict[str, str]]]]
|
||||
|
||||
|
||||
class OsvReport(TypedDict, total=False):
|
||||
"""Keep result bodies untrusted until their explicit validation."""
|
||||
|
||||
results: list[object]
|
||||
|
||||
|
||||
class OsvResult(TypedDict, total=False):
|
||||
"""Describe source identity and packages after checking the object shape."""
|
||||
|
||||
source: dict[str, str]
|
||||
packages: list[object]
|
||||
|
||||
|
||||
class OsvEntry(TypedDict, total=False):
|
||||
"""Describe the coordinate and optional vulnerability array being checked."""
|
||||
|
||||
package: dict[str, str]
|
||||
vulnerabilities: object
|
||||
|
||||
|
||||
REQUIRED = {
|
||||
"com.kingsrook.qqq:qqq-frontend-next",
|
||||
"com.kingsrook.qqq:qqq-backend-core",
|
||||
"com.kingsrook.qqq:qqq-backend-module-filesystem",
|
||||
"com.google.guava:guava",
|
||||
"com.h2database:h2",
|
||||
"com.kingsrook.qqq:qqq-frontend-next",
|
||||
"com.kingsrook.qqq:qqq-backend-core",
|
||||
"com.kingsrook.qqq:qqq-backend-module-filesystem",
|
||||
"com.google.guava:guava",
|
||||
"com.h2database:h2",
|
||||
}
|
||||
REVIEWED_H2_VERSION = "2.4.240"
|
||||
OMITTED = object()
|
||||
|
||||
|
||||
def resolved(text):
|
||||
packages = set()
|
||||
for line in text.splitlines():
|
||||
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip().split(" -- ", 1)[0].strip()
|
||||
if not line or line == "The following files have been resolved:":
|
||||
continue
|
||||
parts = line.split(":")
|
||||
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
|
||||
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
|
||||
if not all(parts) or parts[2] != "jar":
|
||||
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
|
||||
name = parts[0] + ":" + parts[1]
|
||||
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
|
||||
raise ValueError("Test library in runtime inventory")
|
||||
packages.add((name, parts[-2]))
|
||||
if not REQUIRED.issubset({name for name, _ in packages}):
|
||||
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
|
||||
# The pinned dependency-review license exception matcher ignores versions.
|
||||
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
|
||||
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
|
||||
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
|
||||
return packages
|
||||
def resolved(text: str) -> set[tuple[str, str]]:
|
||||
"""Read Maven's selected runtime JARs and enforce the reviewed H2 pin."""
|
||||
packages: set[tuple[str, str]] = set()
|
||||
for raw_line in text.splitlines():
|
||||
line = re.sub(r"\x1b\[[0-9;]*m", "", raw_line).strip().split(" -- ", 1)[0].strip()
|
||||
if not line or line == "The following files have been resolved:":
|
||||
continue
|
||||
parts = line.split(":")
|
||||
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
|
||||
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
|
||||
if not all(parts) or parts[2] != "jar":
|
||||
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
|
||||
name = parts[0] + ":" + parts[1]
|
||||
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
|
||||
raise ValueError("Test library in runtime inventory")
|
||||
packages.add((name, parts[-2]))
|
||||
if not REQUIRED.issubset({name for name, _ in packages}):
|
||||
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
|
||||
# The pinned dependency-review license exception matcher ignores versions.
|
||||
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
|
||||
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
|
||||
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
|
||||
return packages
|
||||
|
||||
|
||||
def components(report):
|
||||
if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX":
|
||||
raise ValueError("Expected CycloneDX inventory")
|
||||
packages = set()
|
||||
for component in report.get("components", []):
|
||||
purl = urlsplit(component.get("purl", ""))
|
||||
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
|
||||
raise ValueError("Missing Maven package URL")
|
||||
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
|
||||
group, artifact = coordinate.split("/", 1)
|
||||
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
|
||||
if not packages:
|
||||
raise ValueError("Empty CycloneDX inventory")
|
||||
return packages
|
||||
def components(report: object) -> set[tuple[str, str]]:
|
||||
"""Extract exact Maven coordinates from the scanner's CycloneDX inventory."""
|
||||
typed_report = cast(Bom, report)
|
||||
if not isinstance(report, dict) or typed_report.get("bomFormat") != "CycloneDX":
|
||||
raise ValueError("Expected CycloneDX inventory")
|
||||
packages: set[tuple[str, str]] = set()
|
||||
for component in typed_report.get("components", []):
|
||||
purl = urlsplit(component.get("purl", ""))
|
||||
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
|
||||
raise ValueError("Missing Maven package URL")
|
||||
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
|
||||
group, artifact = coordinate.split("/", 1)
|
||||
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
|
||||
if not packages:
|
||||
raise ValueError("Empty CycloneDX inventory")
|
||||
return packages
|
||||
|
||||
|
||||
def check(expected, bom, trivy=OMITTED):
|
||||
actual = components(bom)
|
||||
if actual != expected:
|
||||
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
|
||||
if trivy is not OMITTED:
|
||||
if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list):
|
||||
raise ValueError("Expected Trivy report object with Results array")
|
||||
scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])}
|
||||
# Trivy also reports the root project represented by SBOM metadata.
|
||||
root = bom.get("metadata", {}).get("component", {})
|
||||
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
|
||||
if not expected.issubset(scanned) or not scanned.issubset(allowed):
|
||||
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
|
||||
return len(expected)
|
||||
def check(expected: set[tuple[str, str]], bom: object, trivy: object = OMITTED) -> int:
|
||||
"""Require exact SBOM coverage and, when supplied, selected Trivy coverage."""
|
||||
actual = components(bom)
|
||||
if actual != expected:
|
||||
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
|
||||
if trivy is not OMITTED:
|
||||
typed_trivy = cast(TrivyReport, trivy)
|
||||
if not isinstance(trivy, dict) or not isinstance(typed_trivy.get("Results"), list):
|
||||
raise ValueError("Expected Trivy report object with Results array")
|
||||
scanned = {(p["Name"], p["Version"]) for r in typed_trivy.get("Results", []) for p in r.get("Packages", [])}
|
||||
# Trivy also reports the root project represented by SBOM metadata.
|
||||
root = cast(Bom, bom).get("metadata", {}).get("component", {})
|
||||
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
|
||||
if not expected.issubset(scanned) or not scanned.issubset(allowed):
|
||||
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
|
||||
return len(expected)
|
||||
|
||||
|
||||
def check_osv(expected, report):
|
||||
if not isinstance(report, dict) or not isinstance(report.get("results"), list):
|
||||
raise ValueError("Expected OSV report object with results array")
|
||||
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
|
||||
# https://github.com/google/osv-scanner/issues/3099
|
||||
# Fail on ambiguous display coordinates instead of claiming full identity proof.
|
||||
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
|
||||
if any(count != 1 for count in projected.values()):
|
||||
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
|
||||
scanned = Counter()
|
||||
for result in report["results"]:
|
||||
if not isinstance(result, dict) or not isinstance(result.get("source"), dict):
|
||||
raise ValueError("Missing OSV source")
|
||||
if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str):
|
||||
raise ValueError("Expected OSV SBOM source")
|
||||
if not isinstance(result.get("packages"), list):
|
||||
raise ValueError("Missing OSV package inventory; use --all-packages")
|
||||
for entry in result["packages"]:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict):
|
||||
raise ValueError("Malformed OSV package")
|
||||
package = entry["package"]
|
||||
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
|
||||
raise ValueError("Invalid OSV Maven package coordinate")
|
||||
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
|
||||
raise ValueError("Malformed OSV vulnerabilities")
|
||||
scanned[(package["name"], package["version"])] += 1
|
||||
if scanned != projected:
|
||||
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
|
||||
return len(expected)
|
||||
def check_osv(expected: set[tuple[str, str]], report: object) -> int:
|
||||
"""Reject missing, ambiguous, malformed or partial OSV package evidence."""
|
||||
typed_report = cast(OsvReport, report)
|
||||
if not isinstance(report, dict) or not isinstance(typed_report.get("results"), list):
|
||||
raise ValueError("Expected OSV report object with results array")
|
||||
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
|
||||
# https://github.com/google/osv-scanner/issues/3099
|
||||
# Fail on ambiguous display coordinates instead of claiming full identity proof.
|
||||
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
|
||||
if any(count != 1 for count in projected.values()):
|
||||
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
|
||||
scanned: Counter[tuple[str, str]] = Counter()
|
||||
for raw_result in cast(list[object], typed_report.get("results")):
|
||||
result = cast(OsvResult, raw_result)
|
||||
if not isinstance(raw_result, dict) or not isinstance(result.get("source"), dict):
|
||||
raise ValueError("Missing OSV source")
|
||||
source = cast(dict[str, str], result.get("source"))
|
||||
if source.get("type") != "sbom" or not isinstance(source.get("path"), str):
|
||||
raise ValueError("Expected OSV SBOM source")
|
||||
if not isinstance(result.get("packages"), list):
|
||||
raise ValueError("Missing OSV package inventory; use --all-packages")
|
||||
for raw_entry in cast(list[object], result.get("packages")):
|
||||
entry = cast(OsvEntry, raw_entry)
|
||||
if not isinstance(raw_entry, dict) or not isinstance(entry.get("package"), dict):
|
||||
raise ValueError("Malformed OSV package")
|
||||
package = cast(dict[str, str], entry.get("package"))
|
||||
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
|
||||
raise ValueError("Invalid OSV Maven package coordinate")
|
||||
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
|
||||
raise ValueError("Malformed OSV vulnerabilities")
|
||||
scanned[(package["name"], package["version"])] += 1
|
||||
if scanned != projected:
|
||||
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
|
||||
return len(expected)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("resolved", type=Path)
|
||||
parser.add_argument("bom", type=Path)
|
||||
parser.add_argument("trivy", type=Path, nargs="?")
|
||||
parser.add_argument("--osv", type=Path)
|
||||
args = parser.parse_args()
|
||||
expected = resolved(args.resolved.read_text(encoding="utf-8"))
|
||||
count = check(expected,
|
||||
json.loads(args.bom.read_text(encoding="utf-8")),
|
||||
json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
|
||||
if args.osv:
|
||||
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
|
||||
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
|
||||
def main() -> None:
|
||||
"""Validate the exact evidence files supplied by the required CI job."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("resolved", type=Path)
|
||||
parser.add_argument("bom", type=Path)
|
||||
parser.add_argument("trivy", type=Path, nargs="?")
|
||||
parser.add_argument("--osv", type=Path)
|
||||
args = parser.parse_args()
|
||||
expected = resolved(args.resolved.read_text(encoding="utf-8"))
|
||||
count = check(expected, json.loads(args.bom.read_text(encoding="utf-8")), json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
|
||||
if args.osv:
|
||||
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
|
||||
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
|
||||
SCRIPT_PATTERN = ".github/scripts/*.py"
|
||||
PYRIGHT_CONFIG = ".github"
|
||||
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
|
||||
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
|
||||
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
|
||||
|
||||
|
||||
def tracked_files(root: Path) -> list[str]:
|
||||
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
|
||||
# Fixed Git inventory operation; the root is an argument, never shell input.
|
||||
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
return [name for name in output.split("\u0000") if name]
|
||||
|
||||
|
||||
def python_scope(tracked: list[str]) -> list[str]:
|
||||
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
|
||||
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
|
||||
if outside:
|
||||
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned Python scripts selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
|
||||
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
|
||||
tsconfig = root / "tsconfig.json"
|
||||
if not tsconfig.is_file():
|
||||
if candidates:
|
||||
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
|
||||
return []
|
||||
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
|
||||
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
|
||||
if outside:
|
||||
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned JavaScript sources selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def run_pyright(root: Path, config: str) -> dict[str, Any]:
|
||||
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
raise ValueError("Node.js is required to run the locked Pyright")
|
||||
if not PYRIGHT_ENTRY.is_file():
|
||||
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
|
||||
# Resolved Node and locked entry script; the config is an argument, never shell input.
|
||||
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
|
||||
try:
|
||||
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
|
||||
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
|
||||
report["exitCode"] = completed.returncode
|
||||
except (ValueError, KeyError) as error:
|
||||
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
|
||||
return report
|
||||
|
||||
|
||||
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
|
||||
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
|
||||
summary = report["summary"]
|
||||
analyzed = summary["filesAnalyzed"]
|
||||
if analyzed == 0 or analyzed != expected:
|
||||
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
|
||||
if summary["errorCount"] or summary["warningCount"]:
|
||||
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
|
||||
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
|
||||
if report["exitCode"] != 0:
|
||||
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
|
||||
|
||||
|
||||
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
|
||||
"""Run Ruff on the explicit source list so no selected file can be skipped."""
|
||||
# Fixed interpreter module invocation; paths are arguments, never shell input.
|
||||
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
|
||||
raise ValueError("Ruff " + " ".join(arguments) + " failed")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tracked = tracked_files(root)
|
||||
sources = python_scope(tracked)
|
||||
javascript = javascript_scope(root, tracked)
|
||||
run_ruff(root, ["format", "--check"], sources)
|
||||
run_ruff(root, ["check"], sources)
|
||||
report = run_pyright(root, PYRIGHT_CONFIG)
|
||||
check_pyright_report(report, len(sources))
|
||||
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
def leading_comments(text: str, style: str) -> str:
|
||||
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
|
||||
"""Read comments only, preserving required interpreter/XML directive ordering."""
|
||||
text = text.removeprefix("\ufeff")
|
||||
if text.startswith("<?xml"):
|
||||
declaration = re.match(r"<\?xml[^?]*\?>", text)
|
||||
if style != "xml" or declaration is None:
|
||||
raise ValueError("Invalid XML declaration")
|
||||
text = text[declaration.end():]
|
||||
text = text[declaration.end() :]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
@@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str:
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments = []
|
||||
comments: list[str] = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
@@ -46,11 +46,9 @@ def leading_comments(text: str, style: str) -> str:
|
||||
elif style == "slash" and text.startswith("//"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[2:])
|
||||
elif ((style == "slash" and text.startswith("/*"))
|
||||
or (style == "xml" and text.startswith("<!--"))
|
||||
or (style == "powershell" and text.startswith("<#"))):
|
||||
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening):].partition(closing)
|
||||
body, separator, rest = text[len(opening) :].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
@@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
|
||||
for notice in approved:
|
||||
holder = re.escape(notice["holder"])
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
if re.search(copyright_pattern, comments) is None:
|
||||
continue
|
||||
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
|
||||
if spdx and any(value != notice["license"] for value in spdx):
|
||||
continue
|
||||
apache = all(phrase in comments for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
))
|
||||
apache = all(
|
||||
phrase in comments
|
||||
for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
)
|
||||
)
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
@@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
|
||||
failures = []
|
||||
# Fixed Git inventory operation; paths are arguments, never shell input.
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
failures: list[str] = []
|
||||
count = 0
|
||||
for name in tracked:
|
||||
if not name or not selected(name, policy):
|
||||
|
||||
@@ -2,107 +2,117 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Synthetic regressions for missing, incorrect and empty scanner inventories."""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, override
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
inventory = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(inventory)
|
||||
|
||||
|
||||
class InventoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
|
||||
self.text = "The following files have been resolved:\n" + "\n".join(
|
||||
f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
|
||||
self.bom = {"bomFormat": "CycloneDX", "components": [
|
||||
{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"}
|
||||
for name, version in self.expected]}
|
||||
self.trivy = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
|
||||
"""Exercise selected dependency coverage using isolated synthetic reports."""
|
||||
|
||||
def test_complete_graph_without_findings_passes(self):
|
||||
self.assertEqual(self.expected, inventory.resolved(self.text))
|
||||
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Create isolated synthetic evidence with cleanup after each test."""
|
||||
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
|
||||
self.text = "The following files have been resolved:\n" + "\n".join(f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
|
||||
self.bom: dict[str, Any] = {"bomFormat": "CycloneDX", "components": [{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} for name, version in self.expected]}
|
||||
self.trivy: dict[str, Any] = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
|
||||
|
||||
def test_missing_or_unreviewed_h2_version_fails(self):
|
||||
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
|
||||
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"),
|
||||
self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
inventory.resolved(text)
|
||||
def test_complete_graph_without_findings_passes(self) -> None:
|
||||
"""Verify complete graph without findings passes."""
|
||||
self.assertEqual(self.expected, inventory.resolved(self.text))
|
||||
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
|
||||
|
||||
def test_missing_frontend_and_empty_maven_fail(self):
|
||||
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.resolved(text)
|
||||
def test_missing_or_unreviewed_h2_version_fails(self) -> None:
|
||||
"""Verify missing or unreviewed h2 version fails."""
|
||||
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
|
||||
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
inventory.resolved(text)
|
||||
|
||||
def test_test_scope_or_test_jar_fails(self):
|
||||
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.resolved(self.text + "\n" + extra)
|
||||
def test_missing_frontend_and_empty_maven_fail(self) -> None:
|
||||
"""Verify missing frontend and empty maven fail."""
|
||||
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.resolved(text)
|
||||
|
||||
def test_empty_malformed_or_missing_component_fails(self):
|
||||
for bom in ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]},
|
||||
{"bomFormat": "CycloneDX", "components": self.bom["components"][1:]}):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, bom)
|
||||
def test_test_scope_or_test_jar_fails(self) -> None:
|
||||
"""Verify test scope or test jar fails."""
|
||||
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.resolved(self.text + "\n" + extra)
|
||||
|
||||
def test_wrong_selected_version_fails(self):
|
||||
bad = copy.deepcopy(self.trivy)
|
||||
for package in bad["Results"][0]["Packages"]:
|
||||
if package["Name"] == "org.jspecify:jspecify":
|
||||
package["Version"] = "1.0.1"
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, self.bom, bad)
|
||||
def test_empty_malformed_or_missing_component_fails(self) -> None:
|
||||
"""Verify empty malformed or missing component fails."""
|
||||
for bom in cast(tuple[object, ...], ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]})):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, bom)
|
||||
|
||||
def test_empty_or_dropped_trivy_package_fails(self):
|
||||
for report in (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]}):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, self.bom, report)
|
||||
def test_wrong_selected_version_fails(self) -> None:
|
||||
"""Verify wrong selected version fails."""
|
||||
bad = copy.deepcopy(self.trivy)
|
||||
for package in bad["Results"][0]["Packages"]:
|
||||
if package["Name"] == "org.jspecify:jspecify":
|
||||
package["Version"] = "1.0.1"
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, self.bom, bad)
|
||||
|
||||
def test_cli_supplied_null_trivy_fails(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
|
||||
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
|
||||
(directory / "trivy.json").write_text("null", encoding="utf-8")
|
||||
result = subprocess.run([sys.executable, str(Path(inventory.__file__)),
|
||||
str(directory / "resolved.txt"), str(directory / "bom.json"),
|
||||
str(directory / "trivy.json")], capture_output=True, text=True, check=False)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("Expected Trivy report object", result.stderr)
|
||||
def test_empty_or_dropped_trivy_package_fails(self) -> None:
|
||||
"""Verify empty or dropped trivy package fails."""
|
||||
for report in cast(tuple[object, ...], (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]})):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check(self.expected, self.bom, report)
|
||||
|
||||
def osv(self):
|
||||
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [
|
||||
{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}}
|
||||
for name, version in self.expected]}]}
|
||||
def test_cli_supplied_null_trivy_fails(self) -> None:
|
||||
"""Verify cli supplied null trivy fails."""
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
|
||||
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
|
||||
(directory / "trivy.json").write_text("null", encoding="utf-8")
|
||||
assert inventory.__file__ is not None
|
||||
result = subprocess.run([sys.executable, str(Path(inventory.__file__)), str(directory / "resolved.txt"), str(directory / "bom.json"), str(directory / "trivy.json")], capture_output=True, text=True, check=False) # noqa: S603 -- Execute this interpreter and the fixed sibling guard using synthetic paths, without a shell.
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("Expected Trivy report object", result.stderr)
|
||||
|
||||
def test_osv_all_clean_packages_pass_without_vulnerabilities(self):
|
||||
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
|
||||
def osv(self) -> dict[str, Any]:
|
||||
"""Build a clean OSV fixture whose identity matches the selected graph."""
|
||||
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} for name, version in self.expected]}]}
|
||||
|
||||
def test_osv_missing_malformed_or_partial_evidence_fails(self):
|
||||
partial = self.osv()
|
||||
partial["results"][0]["packages"].pop()
|
||||
wrong = self.osv()
|
||||
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
|
||||
duplicate = self.osv()
|
||||
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
|
||||
for report in (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate):
|
||||
with self.subTest(report=report), self.assertRaises(ValueError):
|
||||
inventory.check_osv(self.expected, report)
|
||||
def test_osv_all_clean_packages_pass_without_vulnerabilities(self) -> None:
|
||||
"""Verify osv all clean packages pass without vulnerabilities."""
|
||||
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
|
||||
|
||||
def test_osv_ambiguous_group_projection_fails(self):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
|
||||
def test_osv_missing_malformed_or_partial_evidence_fails(self) -> None:
|
||||
"""Verify osv missing malformed or partial evidence fails."""
|
||||
partial = self.osv()
|
||||
partial["results"][0]["packages"].pop()
|
||||
wrong = self.osv()
|
||||
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
|
||||
duplicate = self.osv()
|
||||
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
|
||||
for report in cast(tuple[object, ...], (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate)):
|
||||
with self.subTest(report=report), self.assertRaises(ValueError):
|
||||
inventory.check_osv(self.expected, report)
|
||||
|
||||
def test_osv_ambiguous_group_projection_fails(self) -> None:
|
||||
"""Verify osv ambiguous group projection fails."""
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
@@ -2,91 +2,106 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Exercise the exact CI guards using synthetic evidence only."""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import io
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, override
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
def load(name: str) -> ModuleType:
|
||||
"""Load the real sibling guard for synthetic evidence tests."""
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
codeql = load("check-codeql-sarif")
|
||||
|
||||
|
||||
class SarifTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
|
||||
"""Reject incomplete SARIF evidence and preserve severity enforcement."""
|
||||
|
||||
def save(self, report):
|
||||
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Create isolated synthetic evidence with cleanup after each test."""
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
|
||||
|
||||
def test_clean_report_with_zero_findings_passes(self):
|
||||
self.save(self.report)
|
||||
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
|
||||
def save(self, report: object) -> None:
|
||||
"""Write the test body as SARIF without validating it first."""
|
||||
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
|
||||
|
||||
def test_missing_report_or_wrong_language_fails(self):
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "csharp", 7)
|
||||
def test_clean_report_with_zero_findings_passes(self) -> None:
|
||||
"""Verify clean report with zero findings passes."""
|
||||
self.save(self.report)
|
||||
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
|
||||
|
||||
def test_empty_or_malformed_report_fails(self):
|
||||
for text in ["", "{", "null", "[]"]:
|
||||
with self.subTest(text=text):
|
||||
(self.directory / "java.sarif").write_text(text)
|
||||
with self.assertRaises((ValueError, AttributeError)):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
def test_missing_report_or_wrong_language_fails(self) -> None:
|
||||
"""Verify missing report or wrong language fails."""
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "csharp", 7)
|
||||
|
||||
def test_missing_runs_tool_rules_or_results_fails(self):
|
||||
variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
|
||||
for field in ["tool", "results"]:
|
||||
bad = copy.deepcopy(self.report)
|
||||
del bad["runs"][0][field]
|
||||
variants.append(bad)
|
||||
bad = copy.deepcopy(self.report)
|
||||
bad["runs"][0]["tool"]["driver"]["rules"] = []
|
||||
variants.append(bad)
|
||||
for bad in variants:
|
||||
with self.subTest(report=bad):
|
||||
self.save(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
def test_empty_or_malformed_report_fails(self) -> None:
|
||||
"""Verify empty or malformed report fails."""
|
||||
for text in ["", "{", "null", "[]"]:
|
||||
with self.subTest(text=text):
|
||||
(self.directory / "java.sarif").write_text(text)
|
||||
with self.assertRaises((ValueError, AttributeError)):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_high_finding_blocks_and_unknown_rule_fails(self):
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
|
||||
self.save(self.report)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
def test_missing_runs_tool_rules_or_results_fails(self) -> None:
|
||||
"""Verify missing runs tool rules or results fails."""
|
||||
variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
|
||||
for field in ["tool", "results"]:
|
||||
bad = copy.deepcopy(self.report)
|
||||
del bad["runs"][0][field]
|
||||
variants.append(bad)
|
||||
bad = copy.deepcopy(self.report)
|
||||
bad["runs"][0]["tool"]["driver"]["rules"] = []
|
||||
variants.append(bad)
|
||||
for bad in variants:
|
||||
with self.subTest(report=bad):
|
||||
self.save(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_failed_invocation_and_invalid_threshold_fail(self):
|
||||
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", float("nan"))
|
||||
def test_high_finding_blocks_and_unknown_rule_fails(self) -> None:
|
||||
"""Verify high finding blocks and unknown rule fails."""
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
|
||||
self.save(self.report)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_failed_invocation_and_invalid_threshold_fail(self) -> None:
|
||||
"""Verify failed invocation and invalid threshold fail."""
|
||||
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", float("nan"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
@@ -0,0 +1,238 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, override
|
||||
from unittest import mock
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n'
|
||||
UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n'
|
||||
GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n"
|
||||
BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n"
|
||||
GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n"
|
||||
INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n'
|
||||
|
||||
|
||||
def load(name: str) -> ModuleType:
|
||||
"""Load the real sibling guard for tests."""
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
guard = load("check-script-quality")
|
||||
|
||||
|
||||
def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
"""Run one real tool invocation without a shell and capture its diagnostics."""
|
||||
return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell.
|
||||
|
||||
|
||||
def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the locked Ruff with the repository configuration on one private fixture."""
|
||||
return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent)
|
||||
|
||||
|
||||
class ScopeTests(unittest.TestCase):
|
||||
"""Reject tracked sources that fall outside the explicit tool roots."""
|
||||
|
||||
def test_python_outside_script_root_is_rejected(self) -> None:
|
||||
"""Reject a tracked Python file outside the script root instead of letting it disappear."""
|
||||
for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"):
|
||||
guard.python_scope([".github/scripts/check-a.py", stray])
|
||||
|
||||
def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None:
|
||||
"""Select only the direct script root, sorted, and reject selecting nothing."""
|
||||
self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"]))
|
||||
with self.assertRaisesRegex(ValueError, "No owned Python"):
|
||||
guard.python_scope(["README.md"])
|
||||
|
||||
def test_javascript_requires_tsconfig_include_scope(self) -> None:
|
||||
"""Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8")
|
||||
self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"]))
|
||||
for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"):
|
||||
guard.javascript_scope(root, ["tools/c.mjs", stray])
|
||||
with self.assertRaisesRegex(ValueError, "No owned JavaScript"):
|
||||
guard.javascript_scope(root, ["README.md"])
|
||||
|
||||
def test_javascript_without_tsconfig_is_rejected(self) -> None:
|
||||
"""Reject JavaScript in a repository that has no tsconfig."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.assertEqual([], guard.javascript_scope(root, ["README.md"]))
|
||||
with self.assertRaisesRegex(ValueError, "without a tsconfig.json"):
|
||||
guard.javascript_scope(root, ["tools/a.mjs"])
|
||||
|
||||
|
||||
class PyrightCountTests(unittest.TestCase):
|
||||
"""Reject Pyright reports that analyzed too few files or found anything."""
|
||||
|
||||
@staticmethod
|
||||
def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]:
|
||||
"""Build the exact Pyright JSON shape consumed by the guard."""
|
||||
return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0}
|
||||
|
||||
def test_nonzero_exit_fails_even_with_clean_summary(self) -> None:
|
||||
"""Fail a nonzero Pyright exit even when every count is clean."""
|
||||
clean = self.report(4)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 4)
|
||||
|
||||
def test_count_must_match_selected_sources(self) -> None:
|
||||
"""Fail zero or mismatched analyzed counts even with no diagnostics."""
|
||||
guard.check_pyright_report(self.report(4), 4)
|
||||
for analyzed in (0, 3, 5):
|
||||
with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"):
|
||||
guard.check_pyright_report(self.report(analyzed), 4)
|
||||
|
||||
def test_errors_and_warnings_fail(self) -> None:
|
||||
"""Fail on errors and on warnings at the exact count."""
|
||||
for report in (self.report(4, errors=1), self.report(4, warnings=1)):
|
||||
with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 4)
|
||||
|
||||
|
||||
class RuffProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Ruff on bounded private fixtures."""
|
||||
|
||||
def test_compliant_fixture_passes_and_violations_fail(self) -> None:
|
||||
"""Pass a compliant control and reject unused imports and wrong indentation."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
probe = Path(name) / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
self.assertEqual(0, ruff(["check"], probe).returncode)
|
||||
self.assertEqual(0, ruff(["format", "--check"], probe).returncode)
|
||||
probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n")
|
||||
unused = ruff(["check", "--output-format", "concise"], probe)
|
||||
self.assertNotEqual(0, unused.returncode)
|
||||
self.assertIn("F401", unused.stdout)
|
||||
probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n")
|
||||
indented = ruff(["format", "--check"], probe)
|
||||
self.assertNotEqual(0, indented.returncode)
|
||||
self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower())
|
||||
|
||||
|
||||
class PyrightProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Pyright with the repository strict settings on private projects."""
|
||||
|
||||
@staticmethod
|
||||
def project(root: Path, include: list[str] | None = None) -> None:
|
||||
"""Write the repository Pyright config, without its venv location, into a private project."""
|
||||
config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8"))
|
||||
del config["venvPath"], config["venv"]
|
||||
if include is not None:
|
||||
config["include"] = include
|
||||
(root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
def test_compliant_control_passes_and_strict_violations_fail(self) -> None:
|
||||
"""Analyze the control once with no findings and reject incompatible and unannotated code."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.project(root)
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir()
|
||||
probe = scripts / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
clean = guard.run_pyright(root, ".")
|
||||
self.assertEqual(0, clean["exitCode"])
|
||||
guard.check_pyright_report(clean, 1)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 1)
|
||||
probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n")
|
||||
report = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]})
|
||||
with self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 1)
|
||||
probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n")
|
||||
strict = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]})
|
||||
|
||||
def test_missing_locked_pyright_or_node_is_rejected(self) -> None:
|
||||
"""Reject a missing locked Pyright entry or Node instead of silently skipping the type gate."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"):
|
||||
guard.run_pyright(root, ".")
|
||||
with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"):
|
||||
guard.run_pyright(root, ".")
|
||||
|
||||
def test_zero_analyzed_files_cannot_pass(self) -> None:
|
||||
"""Reject an empty include and a hidden-directory root config in the wrapper."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
scripts = root / ".github" / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
for include in (["nothing/*.py"], [".github/scripts/*.py"]):
|
||||
self.project(root, include)
|
||||
with self.subTest(include=include), self.assertRaises(ValueError):
|
||||
guard.check_pyright_report(guard.run_pyright(root, "."), 1)
|
||||
|
||||
|
||||
class JavaScriptProbeTests(unittest.TestCase):
|
||||
"""Run the real locked ESLint and TypeScript on bounded private input."""
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Skip only where tsconfig is absent, because the scope guard forbids JavaScript there."""
|
||||
if not (ROOT / "tsconfig.json").is_file():
|
||||
self.skipTest("No JavaScript tooling in this repository")
|
||||
node = shutil.which("node")
|
||||
self.assertIsNotNone(node)
|
||||
self.node = str(node)
|
||||
|
||||
def eslint(self, text: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Lint text as an in-scope tool file using the repository configuration."""
|
||||
return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text)
|
||||
|
||||
def test_eslint_rejects_wrong_layout(self) -> None:
|
||||
"""Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon."""
|
||||
good = self.eslint(GOOD_JAVASCRIPT)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
bad = self.eslint(BAD_LAYOUT_JAVASCRIPT)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]}
|
||||
self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules)
|
||||
|
||||
def test_checked_javascript_rejects_incompatible_assignment(self) -> None:
|
||||
"""Reject a real incompatible assignment under the repository strict checkJs settings."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8")
|
||||
tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")]
|
||||
(root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
good = run(tsc, root)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
(root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
bad = run(tsc, root)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
self.assertIn("TS2322", bad.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,7 +11,6 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
HEADERS = importlib.util.module_from_spec(SPEC)
|
||||
@@ -32,8 +31,7 @@ limitations under the License."""
|
||||
|
||||
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
|
||||
"""Use the production styles with a small explicit owned fixture scope."""
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
|
||||
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
@@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
|
||||
"""Accept a hyphen or en dash between years and reject any other separator."""
|
||||
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
|
||||
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
|
||||
with self.subTest(separator=separator):
|
||||
if accepted:
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_required_first_lines_and_bom(self) -> None:
|
||||
"""Accept shebangs, PowerShell requirements and XML declarations first."""
|
||||
for name, prefix, body in (
|
||||
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
|
||||
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
|
||||
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
@@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase):
|
||||
for name, text in (
|
||||
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
|
||||
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
|
||||
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
|
||||
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_code_strings_and_late_comments_do_not_count(self) -> None:
|
||||
"""Reject docstrings and comments after executable declarations."""
|
||||
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
|
||||
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
|
||||
"""Notice text alone is insufficient without a complete language comment."""
|
||||
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
|
||||
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase):
|
||||
|
||||
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
|
||||
"""Do not conflate repository licenses or accept conflicting identifiers."""
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
|
||||
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
@@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase):
|
||||
def test_java_requires_full_apache_block(self) -> None:
|
||||
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
|
||||
APACHE.replace("limitations under the License.", "")):
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
@@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase):
|
||||
"""New scripts enter the guard; data, generated and upstream files do not."""
|
||||
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
|
||||
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
|
||||
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
|
||||
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
@@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase):
|
||||
"""A tracked new source fails while untracked generated output is ignored."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
|
||||
root.joinpath("src").mkdir()
|
||||
source = root / "src/a.py"
|
||||
source.write_text("print(1)", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
|
||||
with self.assertRaisesRegex(ValueError, "src/a.py"):
|
||||
HEADERS.check_repository(root, fixture_policy())
|
||||
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
|
||||
|
||||
@@ -36,12 +36,31 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Restore locked Pyright
|
||||
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.13.6'
|
||||
enable-cache: false
|
||||
- name: Restore locked Python tools
|
||||
run: uv sync --locked
|
||||
- name: Verify script quality gates and rejection paths
|
||||
run: |
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
- name: Verify owned source notices and rejection paths
|
||||
run: |
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
- name: Checkstyle, unit tests and HTTP contract tests
|
||||
run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify
|
||||
- name: Prove main and test compiler safety gates reject invalid source
|
||||
run: python3 .github/scripts/check-java-compiler.py
|
||||
- name: Prepare JAR, POM and checksums
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -67,6 +86,7 @@ jobs:
|
||||
path: |
|
||||
qbit-printing/target/surefire-reports/
|
||||
qbit-printing/target/checkstyle-result.xml
|
||||
qbit-printing/target/compiler-guard/
|
||||
if-no-files-found: ignore
|
||||
|
||||
release-security:
|
||||
|
||||
@@ -50,6 +50,48 @@ jobs:
|
||||
$configuration.TestRegistry.Enabled = $false
|
||||
Invoke-Pester -Configuration $configuration
|
||||
|
||||
clients:
|
||||
name: development-clients (${{ matrix.fixture }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- fixture: workload
|
||||
dockerfile: dev/Dockerfile.client
|
||||
- fixture: native-auth
|
||||
dockerfile: dev/native-auth/Dockerfile.client
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build strict fixtures and run fake-HTTP discovery checks
|
||||
env:
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: docker build --target build --file "$DOCKERFILE" .
|
||||
|
||||
servers:
|
||||
name: development-servers (${{ matrix.fixture }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- fixture: workload
|
||||
dockerfile: dev/Dockerfile.server
|
||||
- fixture: native-auth
|
||||
dockerfile: dev/native-auth/Dockerfile.server
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build strict Maven project and compile the fixture Java without starting it
|
||||
env:
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: docker build --target build --file "$DOCKERFILE" .
|
||||
|
||||
docker:
|
||||
name: development-docker
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
+10
@@ -12,3 +12,13 @@ dist/
|
||||
*.iml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local script-quality environments and tool caches.
|
||||
.venv/
|
||||
node_modules/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
|
||||
# Isolated .NET developer fixture output.
|
||||
dev/**/bin/
|
||||
dev/**/obj/
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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.
|
||||
@@ -0,0 +1,206 @@
|
||||
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
|
||||
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#.
|
||||
# Copy to the repository root. Severities of "warning" fail the build because the
|
||||
# companion Directory.Build.props sets TreatWarningsAsErrors and EnforceCodeStyleInBuild,
|
||||
# so local `dotnet build` and CI enforce exactly the same rules. The resharper_* keys
|
||||
# apply the Kingsrook blank-line rules in Rider and ReSharper, which .NET's own
|
||||
# formatter cannot express.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
# Kingsrook: always 3 spaces, never tabs.
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Windows-only script formats require CRLF.
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.cs]
|
||||
# All fixture sources are repository-owned Apache-2.0 code.
|
||||
file_header_template = Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0
|
||||
dotnet_diagnostic.IDE0073.severity = warning
|
||||
#### Formatting (IDE0055 makes every rule below a build error) ####
|
||||
dotnet_diagnostic.IDE0055.severity = warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_space_after_cast = false
|
||||
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
|
||||
csharp_space_after_keywords_in_control_flow_statements = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = false
|
||||
|
||||
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
|
||||
resharper_blank_lines_around_method = 3
|
||||
resharper_blank_lines_around_property = 3
|
||||
resharper_blank_lines_around_auto_property = 1
|
||||
resharper_blank_lines_around_type = 3
|
||||
resharper_blank_lines_after_using_list = 2
|
||||
resharper_keep_blank_lines_in_code = 1
|
||||
resharper_keep_blank_lines_in_declarations = 3
|
||||
resharper_brace_style = next_line
|
||||
resharper_indent_size = 3
|
||||
resharper_space_after_keywords_in_control_flow_statements = false
|
||||
resharper_max_line_length = 999
|
||||
resharper_wrap_before_first_method_call = true
|
||||
resharper_wrap_chained_method_calls = chop_if_long
|
||||
|
||||
#### Usings and namespaces ####
|
||||
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_diagnostic.IDE0161.severity = warning
|
||||
|
||||
#### var: only when the type is apparent from the right-hand side ####
|
||||
csharp_style_var_for_built_in_types = false:warning
|
||||
csharp_style_var_when_type_is_apparent = true:warning
|
||||
csharp_style_var_elsewhere = false:warning
|
||||
dotnet_diagnostic.IDE0007.severity = warning
|
||||
dotnet_diagnostic.IDE0008.severity = warning
|
||||
|
||||
#### Braces, modifiers, readonly ####
|
||||
csharp_prefer_braces = true:warning
|
||||
dotnet_diagnostic.IDE0011.severity = warning
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
dotnet_diagnostic.IDE0040.severity = warning
|
||||
dotnet_style_readonly_field = true:warning
|
||||
dotnet_diagnostic.IDE0044.severity = warning
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
|
||||
dotnet_diagnostic.IDE0036.severity = warning
|
||||
|
||||
#### this. qualification: never ####
|
||||
dotnet_style_qualification_for_field = false:warning
|
||||
dotnet_style_qualification_for_property = false:warning
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
dotnet_diagnostic.IDE0003.severity = warning
|
||||
|
||||
#### Language keywords over framework type names ####
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
|
||||
dotnet_style_predefined_type_for_member_access = true:warning
|
||||
dotnet_diagnostic.IDE0049.severity = warning
|
||||
|
||||
#### Dead code and redundancy ####
|
||||
dotnet_diagnostic.IDE0051.severity = warning # unused private member
|
||||
dotnet_diagnostic.IDE0052.severity = warning # unread private member
|
||||
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
|
||||
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
|
||||
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
|
||||
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
|
||||
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
|
||||
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
|
||||
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
|
||||
dotnet_code_quality_unused_parameters = all
|
||||
|
||||
#### Modern language features (suggestions: shown in IDE, not enforced) ####
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
|
||||
#### Naming ####
|
||||
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
|
||||
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
|
||||
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
|
||||
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
|
||||
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
|
||||
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
|
||||
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_static_fields.required_modifiers = static
|
||||
dotnet_naming_style.s_camel_case.required_prefix = s_
|
||||
dotnet_naming_style.s_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.constants_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
#### Analyzer adjustments (each one justified) ####
|
||||
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
|
||||
dotnet_diagnostic.CA1716.severity = none
|
||||
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
|
||||
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
|
||||
dotnet_diagnostic.MA0004.severity = none
|
||||
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
|
||||
dotnet_diagnostic.MA0049.severity = none
|
||||
|
||||
[**/{Generated,obj}/**.cs]
|
||||
generated_code = true
|
||||
|
||||
[{tests,test}/**.cs]
|
||||
# xUnit's Method_Scenario_Expectation names use underscores by convention.
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
# Inline arrays keep test data at the assertion; each test runs once.
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
# Test projects are not documented API.
|
||||
dotnet_diagnostic.CS1591.severity = none
|
||||
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
|
||||
dotnet_diagnostic.MA0048.severity = none
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Keep the SDK formatter identical on Windows and Linux checkouts.
|
||||
*.cs text eol=lf
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<Deterministic>true</Deterministic>
|
||||
<NuGetAudit>true</NuGetAudit>
|
||||
<NuGetAuditMode>all</NuGetAuditMode>
|
||||
<NuGetAuditLevel>low</NuGetAuditLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<CoreAssemblyPath Condition="'$(CoreAssemblyPath)' == ''">/core/Qqq.PrintAgent.Core.dll</CoreAssemblyPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<RestoreLockedMode>true</RestoreLockedMode>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
|
||||
</ItemGroup>
|
||||
<Target Name="RequireProductionCore" BeforeTargets="ResolveAssemblyReferences">
|
||||
<Error Condition="!Exists('$(CoreAssemblyPath)')" Text="Build the pinned production Core source first and provide CoreAssemblyPath; fixture stubs are not supported." />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,9 +7,11 @@ ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fc
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
WORKDIR /fixture
|
||||
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
|
||||
WORKDIR /fixture/dev/client
|
||||
COPY dev/client .
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
|
||||
RUN dotnet format --verify-no-changes --severity warn --no-restore
|
||||
RUN dotnet /out/Workload.dll --check-discovery
|
||||
# SDK image is reused deliberately: one pinned image, no hidden host SDK dependency.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
|
||||
@@ -7,10 +7,11 @@ COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY dev/java dev/java
|
||||
COPY dev/native-auth/java dev/native-auth/java
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Pui-demo -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/java /fixture-src
|
||||
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/java/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
|
||||
+21
-1
@@ -128,6 +128,26 @@ It is opt-in, Test-only, and does not change the default released QQQ dependency
|
||||
|
||||
The development workflow runs the Pester and PowerShell analysis checks on
|
||||
Windows and Ubuntu for every pull request and main-branch push. Ubuntu also runs
|
||||
the real 40-job/S3 Docker smoke. The longer experimental native-auth profile is
|
||||
the real 40-job/S3 Docker smoke. Both client fixture images also have build-only
|
||||
jobs on every pull request, including the optional native client; these jobs run
|
||||
only fake-HTTP discovery checks and do not start a provider.
|
||||
|
||||
Maven Checkstyle checks all four Java fixture sources in `dev/java` and
|
||||
`dev/native-auth/java` during `validate`. They are deliberately **not** Maven
|
||||
compiler sources: the ordinary fixture uses the released QQQ tuple, while the
|
||||
native host compiles only inside its explicitly selected candidate image. Both
|
||||
server Dockerfiles copy both roots before Maven runs so neither image can skip
|
||||
this style coverage.
|
||||
|
||||
The ten C# fixture files share `dev/.editorconfig`, strict build properties,
|
||||
central Meziantou 3.0.296, and committed package locks. The existing pinned SDK
|
||||
image contains .NET SDK 10.0.403, matching `dev/global.json`. Both image builds
|
||||
perform locked, vulnerability-audited restore, warnings-as-errors compilation,
|
||||
formatter verification, and their nine or 12 fake-HTTP discovery cases. Core is
|
||||
still built from the exact public source archive above; no stubs or runtime
|
||||
package dependency were added. For a local build, supply the real pinned Core
|
||||
DLL through `-p:CoreAssemblyPath=/absolute/path/Qqq.PrintAgent.Core.dll`.
|
||||
|
||||
The longer experimental native-auth profile is
|
||||
an explicit manual workflow option; it is not silently substituted for the
|
||||
released dependency baseline.
|
||||
@@ -1,12 +1,24 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercises fixture compatibility decisions using credential-free fake HTTP responses.</summary>
|
||||
internal static class DiscoveryChecks
|
||||
{
|
||||
private static readonly string[] s_features = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
|
||||
|
||||
|
||||
private static readonly string[] s_channels = ["api"];
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify supported stable versions and reject malformed or incompatible versions.</summary>
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string Version, bool Accepted)[] cases =
|
||||
@@ -25,8 +37,14 @@ internal static class DiscoveryChecks
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client, CancellationToken.None);
|
||||
accepted = true;
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired) { }
|
||||
catch(InvalidDataException) { }
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
|
||||
{
|
||||
// Expected for incompatible versions: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidDataException)
|
||||
{
|
||||
// Expected for malformed versions: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
throw new InvalidDataException($"Discovery compatibility case {version} failed.");
|
||||
@@ -35,14 +53,29 @@ internal static class DiscoveryChecks
|
||||
Console.WriteLine($"Discovery compatibility checks passed: {cases.Length}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Supply one version response and reject any premature credential transmission.</summary>
|
||||
private sealed class DiscoveryHandler(string version) : HttpMessageHandler
|
||||
{
|
||||
internal int Requests { get; private set; }
|
||||
internal int Requests
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Return only the synthetic discovery response and count the request.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsolutePath != "/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization"))
|
||||
if(request.Method != HttpMethod.Get
|
||||
|| !string.Equals(request.RequestUri?.AbsolutePath, "/qqq/v1/version", StringComparison.Ordinal)
|
||||
|| request.Headers.Contains("Cookie")
|
||||
|| request.Headers.Contains("Authorization"))
|
||||
{
|
||||
throw new InvalidOperationException("Discovery must precede authentication.");
|
||||
}
|
||||
@@ -50,8 +83,8 @@ internal static class DiscoveryChecks
|
||||
string json = JsonSerializer.Serialize(new
|
||||
{
|
||||
apiVersion = "1.8.0",
|
||||
features = new[] { "version", "metaData", "processes", "processCancel", "extensions", "widgets" },
|
||||
clientChannels = new[] { "api" },
|
||||
features = s_features,
|
||||
clientChannels = s_channels,
|
||||
extensions = new[] { new { name = "printing", version, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
|
||||
+611
-135
@@ -1,7 +1,5 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.WebSockets;
|
||||
@@ -10,140 +8,618 @@ using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
internal static class Program {
|
||||
static int Completed; static readonly byte[] Expected=Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+new string(' ',980)+"^XZ");
|
||||
static async Task<int> Main(string[] args) {
|
||||
if(args is ["--check-discovery"]) { await DiscoveryChecks.RunAsync(); return 0; }
|
||||
string folder=Path.GetFullPath(args[0]); string stage="input";
|
||||
using var timeout=new CancellationTokenSource(TimeSpan.FromMinutes(18));
|
||||
List<Lane> lanes=[]; long peak=0; var timer=Stopwatch.StartNew();
|
||||
try {
|
||||
using JsonDocument input=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder,"input.json")));
|
||||
stage="native_login_and_channels";
|
||||
bool idle=input.RootElement.GetProperty("idle").GetBoolean(); int perLane=input.RootElement.GetProperty("jobsPerLane").GetInt32(); int total=perLane*8; int index=0;
|
||||
foreach(var value in input.RootElement.GetProperty("lanes").EnumerateArray()) {
|
||||
Uri origin=new(value.GetProperty("origin").GetString()!);
|
||||
Require(origin.IsLoopback && origin.Port is not (18081 or 18086),"disposable origin");
|
||||
var client=new QqqSessionClient(origin,allowLoopbackHttp:true);
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client,timeout.Token);
|
||||
await client.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!,value.GetProperty("password").GetString()!,timeout.Token);
|
||||
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!,value.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);
|
||||
var binding=await client.GetSessionIdentityAsync(timeout.Token);
|
||||
Require(binding.TenantId==value.GetProperty("tenant").GetString(),"authoritative tenant");
|
||||
var connection=await Connection.Open(client,timeout.Token);
|
||||
lanes.Add(new(client,connection,Path.Combine(folder,"lane-"+index++),perLane,folder));
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs real Core network workers against synthetic Java services without a physical printer.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
|
||||
|
||||
|
||||
|
||||
private static readonly string[] s_capabilities = ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL"];
|
||||
|
||||
|
||||
|
||||
private static int s_completed;
|
||||
|
||||
|
||||
|
||||
private static readonly byte[] s_expected = Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + new string(' ', 980) + "^XZ");
|
||||
|
||||
|
||||
|
||||
/// <summary>Run the bounded workload or fake-HTTP checks and always signal completion to the launcher.</summary>
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
if(args is ["--check-discovery"])
|
||||
{
|
||||
await DiscoveryChecks.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
string folder = Path.GetFullPath(args[0]);
|
||||
string stage = "input";
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(18));
|
||||
List<Lane> lanes = [];
|
||||
long peak = 0;
|
||||
var timer = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var input = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder, "input.json")));
|
||||
stage = "native_login_and_channels";
|
||||
bool idle = input.RootElement.GetProperty("idle").GetBoolean();
|
||||
int perLane = input.RootElement.GetProperty("jobsPerLane").GetInt32();
|
||||
int total = perLane * 8;
|
||||
await OpenLanesAsync(input.RootElement, lanes, folder, perLane, timeout.Token);
|
||||
bool denied = await RejectForeignProofAsync(input.RootElement, timeout.Token);
|
||||
stage = "workload";
|
||||
peak = await RunLanesAsync(lanes, folder, total, timeout.Token);
|
||||
stage = "logout";
|
||||
foreach(Lane lane in lanes)
|
||||
{
|
||||
await lane.Client.SignOutAsync(timeout.Token);
|
||||
}
|
||||
await WriteSuccessAsync(folder, lanes, total, idle, denied, peak, timer.Elapsed.TotalSeconds);
|
||||
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");
|
||||
return 0;
|
||||
}
|
||||
catch(Exception error)
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
stage,
|
||||
error = error.GetType().Name,
|
||||
reason = error is InvalidDataException ? error.Message : null,
|
||||
completed = lanes.Sum(l => l.Printer.Count)
|
||||
}));
|
||||
Console.Error.WriteLine("FAIL " + stage + ": " + error.GetType().Name);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach(Lane lane in lanes)
|
||||
{
|
||||
await lane.DisposeAsync();
|
||||
}
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "done"), "done");
|
||||
}
|
||||
}
|
||||
Require(lanes.Count==8,"eight lanes");
|
||||
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
|
||||
var foreign=input.RootElement.GetProperty("lanes")[4]; bool denied=false;
|
||||
using(var outsider=new QqqSessionClient(new Uri(input.RootElement.GetProperty("lanes")[0].GetProperty("origin").GetString()!),allowLoopbackHttp:true)) {
|
||||
await outsider.DiscoverAsync(timeout.Token);await outsider.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await outsider.SignInWithPasswordAsync("operator",input.RootElement.GetProperty("lanes")[0].GetProperty("password").GetString()!,timeout.Token);
|
||||
try {await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!,foreign.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);}catch(QqqSessionException){denied=true;}
|
||||
Require(denied,"cross tenant proof refused");await outsider.SignOutAsync(timeout.Token);
|
||||
|
||||
|
||||
|
||||
/// <summary>Authenticate and bind eight synthetic lanes before admitting fixture work.</summary>
|
||||
private static async Task OpenLanesAsync(JsonElement input, List<Lane> lanes, string folder, int perLane, CancellationToken token)
|
||||
{
|
||||
int index = 0;
|
||||
foreach(JsonElement value in input.GetProperty("lanes").EnumerateArray())
|
||||
{
|
||||
Uri origin = new(value.GetProperty("origin").GetString()!);
|
||||
Require(origin.IsLoopback && origin.Port is not (18081 or 18086), "disposable origin");
|
||||
var client = new QqqSessionClient(origin, allowLoopbackHttp: true);
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
|
||||
await client.GetAuthenticationMetadataAsync(token);
|
||||
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!, value.GetProperty("password").GetString()!, token);
|
||||
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!, value.GetProperty("deviceToken").GetString()!), cancellationToken: token);
|
||||
QqqSessionBinding binding = await client.GetSessionIdentityAsync(token);
|
||||
Require(string.Equals(binding.TenantId, value.GetProperty("tenant").GetString(), StringComparison.Ordinal), "authoritative tenant");
|
||||
Connection connection = await Connection.Open(client, token);
|
||||
lanes.Add(new(client, connection, Path.Combine(folder, "lane-" + index++), perLane, folder));
|
||||
}
|
||||
Require(lanes.Count == 8, "eight lanes");
|
||||
}
|
||||
stage="workload";
|
||||
var tasks=lanes.Select(lane=>lane.Run(timeout.Token)).ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"ready"),"ready",timeout.Token);
|
||||
Task all=Task.WhenAll(tasks);
|
||||
while(!all.IsCompleted) {
|
||||
peak=Math.Max(peak,Process.GetCurrentProcess().WorkingSet64);
|
||||
await Task.WhenAny(all,Task.Delay(1000,timeout.Token));timeout.Token.ThrowIfCancellationRequested();
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify that authenticated tenant A cannot adopt tenant B's device proof.</summary>
|
||||
private static async Task<bool> RejectForeignProofAsync(JsonElement input, CancellationToken token)
|
||||
{
|
||||
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
|
||||
JsonElement foreign = input.GetProperty("lanes")[4];
|
||||
bool denied = false;
|
||||
using(var outsider = new QqqSessionClient(new Uri(input.GetProperty("lanes")[0].GetProperty("origin").GetString()!), allowLoopbackHttp: true))
|
||||
{
|
||||
await outsider.DiscoverAsync(token);
|
||||
await outsider.GetAuthenticationMetadataAsync(token);
|
||||
await outsider.SignInWithPasswordAsync("operator", input.GetProperty("lanes")[0].GetProperty("password").GetString()!, token);
|
||||
try
|
||||
{
|
||||
await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!, foreign.GetProperty("deviceToken").GetString()!), cancellationToken: token);
|
||||
}
|
||||
catch(QqqSessionException) { denied = true; }
|
||||
Require(denied, "cross tenant proof refused");
|
||||
await outsider.SignOutAsync(token);
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Write only aggregate network, accounting, and fake-printer evidence.</summary>
|
||||
private static async Task WriteSuccessAsync(string folder, List<Lane> lanes, int total, bool idle, bool denied, long peak, double elapsedSeconds)
|
||||
{
|
||||
double[] latencies = lanes.SelectMany(l => l.Latencies).Order().ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
success = true,
|
||||
jobs = total,
|
||||
idle,
|
||||
tenants = 2,
|
||||
devices = 8,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
jobsPerSecond = total / elapsedSeconds,
|
||||
peakClientWorkingSetBytes = peak,
|
||||
p50EnqueueToAckMillis = latencies[total / 2],
|
||||
p95EnqueueToAckMillis = latencies[(int)(total * 0.95)],
|
||||
p99EnqueueToAckMillis = latencies[(int)(total * 0.99)],
|
||||
realCoreWorker = true,
|
||||
fakePrinterBackend = true,
|
||||
fixtureJournal = true,
|
||||
serverReceiptsDurable = true,
|
||||
crossTenantProofRefused = denied,
|
||||
submissionReplays = idle ? 0 : 400,
|
||||
receiptReplays = idle ? 0 : 400,
|
||||
notificationToClaimMillis = lanes.SelectMany(l => l.ClaimLatency).ToArray(),
|
||||
notificationToDownloadCompletedMillis = lanes.SelectMany(l => l.DownloadLatency).ToArray(),
|
||||
periodicJobPolling = false,
|
||||
transport = "HTTP/WS loopback; no TLS qualification",
|
||||
realPrinterCalls = 0,
|
||||
runtime = Environment.Version.ToString(),
|
||||
coreVersion = typeof(PrintWorker).Assembly.GetName().Version?.ToString()
|
||||
}, s_reportOptions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Drain only event-triggered work and measure the peak client working set.</summary>
|
||||
private static async Task<long> RunLanesAsync(List<Lane> lanes, string folder, int total, CancellationToken token)
|
||||
{
|
||||
long peak = 0;
|
||||
Task[] tasks = lanes.Select(lane => lane.Run(token)).ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "ready"), "ready", token);
|
||||
var all = Task.WhenAll(tasks);
|
||||
while(!all.IsCompleted)
|
||||
{
|
||||
peak = Math.Max(peak, Process.GetCurrentProcess().WorkingSet64);
|
||||
await Task.WhenAny(all, Task.Delay(1000, token));
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
await all;
|
||||
Require(lanes.Sum(l => l.Printer.Count) == total, "exact fake submission count");
|
||||
Require(lanes.All(l => l.Journal.Pending().Count == 0), "no unacknowledged receipts");
|
||||
return peak;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop the fixture when an invariant fails, retaining a fixed diagnostic reason.</summary>
|
||||
private static void Require(bool ok, string reason)
|
||||
{
|
||||
if(!ok)
|
||||
{
|
||||
throw new InvalidDataException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
|
||||
private sealed class Lane : IAsyncDisposable
|
||||
{
|
||||
public QqqSessionClient Client
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Connection Connection
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public FakePrinter Printer { get; } = new();
|
||||
|
||||
|
||||
|
||||
public Journal Journal
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public List<double> Latencies { get; } = [];
|
||||
|
||||
|
||||
|
||||
private readonly PrintApiClient _api;
|
||||
|
||||
|
||||
|
||||
private readonly PrintWorker _worker;
|
||||
|
||||
|
||||
|
||||
private readonly int _target;
|
||||
|
||||
|
||||
|
||||
private readonly string _root;
|
||||
|
||||
|
||||
|
||||
private long _wakeAt;
|
||||
|
||||
|
||||
|
||||
public List<double> ClaimLatency { get; } = [];
|
||||
|
||||
|
||||
|
||||
public List<double> DownloadLatency { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
|
||||
public Lane(QqqSessionClient client, Connection connection, string folder, int targetJobs, string rootDirectory)
|
||||
{
|
||||
Client = client;
|
||||
Connection = connection;
|
||||
_target = targetJobs;
|
||||
_root = rootDirectory;
|
||||
Printer.OnDownload = () => DownloadLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
|
||||
Directory.CreateDirectory(folder);
|
||||
Journal = new(folder);
|
||||
_api = PrintApiClient.ForSession(client).WithRealtime(connection.Channel, connection.Settings);
|
||||
_worker = new PrintWorker(_api, Printer, Journal, options: new PrintWorkerOptions
|
||||
{
|
||||
Settings = connection.Settings,
|
||||
StagingDirectory = Path.Combine(folder, "staging"),
|
||||
SynchronizeInventory = false,
|
||||
RecoverIncomplete = false,
|
||||
ActiveJobChanged = job =>
|
||||
{
|
||||
if(job is not null)
|
||||
{
|
||||
ClaimLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Drain notified jobs and await durable receipts without periodic job polling.</summary>
|
||||
public async Task Run(CancellationToken token)
|
||||
{
|
||||
using var stop = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task ping = Liveness(stop.Token);
|
||||
try
|
||||
{
|
||||
while(Printer.Count < _target)
|
||||
{
|
||||
_wakeAt = await Connection.Wakes.Reader.ReadAsync(token);
|
||||
while(Printer.Count < _target && await _worker.RunOnceAsync(token))
|
||||
{
|
||||
Require(string.Equals(Journal.Last.Event.Status, "SPOOLED", StringComparison.Ordinal), "synthetic job terminal status");
|
||||
Latencies.Add((DateTimeOffset.UtcNow - DateTimeOffset.Parse(Journal.LastJob!.CreatedAt, CultureInfo.InvariantCulture)).TotalMilliseconds);
|
||||
await File.WriteAllTextAsync(Path.Combine(_root, "completed-" + Interlocked.Increment(ref s_completed)), "acknowledged", token);
|
||||
if(Printer.Count % 25 == 0)
|
||||
{
|
||||
await _api.EventAsync(Journal.Last.JobId, Journal.Last.Event, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stop.CancelAsync();
|
||||
try
|
||||
{
|
||||
await ping;
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
// Cancellation ends only the owned liveness task.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send transport liveness frames without querying for jobs.</summary>
|
||||
private async Task Liveness(CancellationToken token)
|
||||
{
|
||||
using var tick = new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
|
||||
while(await tick.WaitForNextTickAsync(token))
|
||||
{
|
||||
await Connection.Channel.SendAsync("PING", new
|
||||
{
|
||||
}, token: token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_api.Dispose();
|
||||
await Connection.DisposeAsync();
|
||||
Client.Dispose();
|
||||
Journal.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify payload bytes and reject duplicate actions without calling a spooler.</summary>
|
||||
private sealed class FakePrinter : IFilePrinterBackend
|
||||
{
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
public int Count => _seen.Count;
|
||||
|
||||
|
||||
|
||||
public Action? OnDownload
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Expose only the fixed synthetic ZPL destination.</summary>
|
||||
public IReadOnlyList<PrinterInfo> Discover() => [new("synthetic", "No physical printer", "fixture", "none", "READY", ["ZPL"])];
|
||||
|
||||
|
||||
|
||||
/// <summary>Record download completion and validate the staged synthetic payload.</summary>
|
||||
public async Task<string> PrintFileAsync(PrintJob job, string path, CancellationToken token)
|
||||
{
|
||||
OnDownload?.Invoke();
|
||||
return await PrintAsync(job, await File.ReadAllBytesAsync(path, token), token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Check exact bytes and allow each synthetic job to be submitted once.</summary>
|
||||
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken token)
|
||||
{
|
||||
Require(content.AsSpan().SequenceEqual(s_expected), "downloaded bytes exact");
|
||||
Require(_seen.Add(job.JobId), "no duplicate physical action");
|
||||
return Task.FromResult("synthetic-" + Count);
|
||||
}
|
||||
}
|
||||
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
|
||||
|
||||
|
||||
|
||||
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
|
||||
private sealed class Journal : IJobJournal, IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string> _states = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly Dictionary<string, (string, PrintEvent)> _pending = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly StreamWriter _log;
|
||||
|
||||
|
||||
|
||||
public PrintJob? LastJob
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public (string JobId, PrintEvent Event) Last
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
|
||||
public Journal(string folder)
|
||||
{
|
||||
_log = new StreamWriter(new FileStream(Path.Combine(folder, "synthetic-receipts.jsonl"), FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { AutoFlush = true };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Retain accepted job metadata for latency and status assertions.</summary>
|
||||
public void TrackJob(PrintJob job, string? printer)
|
||||
{
|
||||
LastJob = job;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Return the last recorded state for the synthetic job.</summary>
|
||||
public string? GetState(string id) => _states.GetValueOrDefault(id);
|
||||
|
||||
|
||||
|
||||
/// <summary>Flush a receipt before adding it to the pending set.</summary>
|
||||
public void Record(string id, PrintEvent evt)
|
||||
{
|
||||
_log.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
jobId = id,
|
||||
@event = evt
|
||||
}));
|
||||
_states[id] = evt.Status;
|
||||
_pending.Add(evt.EventId, (id, evt));
|
||||
Last = (id, evt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Snapshot unacknowledged synthetic receipts for the real worker.</summary>
|
||||
public IReadOnlyList<(string JobId, PrintEvent Event)> Pending() => _pending.Values.ToList();
|
||||
|
||||
|
||||
|
||||
/// <summary>Remove only the specifically acknowledged receipt.</summary>
|
||||
public void Acknowledge(string id)
|
||||
{
|
||||
_pending.Remove(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Declare no recovered jobs because each fixture journal starts fresh.</summary>
|
||||
public IReadOnlyList<(string JobId, string State)> Incomplete() => [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Close the fixture receipt log.</summary>
|
||||
public void Dispose() => _log.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Own the realtime reader, bounded notifications, and negotiated settings.</summary>
|
||||
private sealed class Connection : IAsyncDisposable
|
||||
{
|
||||
public required PrintingRealtimeChannel Channel
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Task Reader
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required CancellationTokenSource Lifetime
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Channel<RealtimeEnvelope> Messages
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Channel<long> Wakes
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public RealtimeSettings Settings
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
} = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>Open an authenticated channel and acknowledge its initial authoritative state.</summary>
|
||||
public static async Task<Connection> Open(QqqSessionClient client, CancellationToken token)
|
||||
{
|
||||
var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
WebSocket socket = await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(60), token);
|
||||
var channel = new PrintingRealtimeChannel(socket, new RealtimeSettings());
|
||||
var messages = System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
|
||||
var wakes = System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
Task reader = channel.RunAsync(e =>
|
||||
{
|
||||
if(string.Equals(e.Type, "JOB_AVAILABLE", StringComparison.Ordinal))
|
||||
{
|
||||
wakes.Writer.TryWrite(Stopwatch.GetTimestamp());
|
||||
return;
|
||||
}
|
||||
if(e.Type is "SETTINGS" or "DESIRED_STATE")
|
||||
{
|
||||
long revision = e.Payload.GetProperty("revision").GetInt64();
|
||||
if(revision != 0)
|
||||
{
|
||||
throw new InvalidDataException("Unexpected policy change");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(e.Type is "PONG" or "ACK")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!messages.Writer.TryWrite(e))
|
||||
{
|
||||
throw new IOException("Fixture envelope inbox full");
|
||||
}
|
||||
}, lifetime.Token);
|
||||
_ = reader.ContinueWith(t => { wakes.Writer.TryComplete(t.Exception); messages.Writer.TryComplete(t.Exception); }, TaskScheduler.Default);
|
||||
var result = new Connection { Channel = channel, Reader = reader, Lifetime = lifetime, Messages = messages, Wakes = wakes };
|
||||
await channel.SendAsync("HELLO", new
|
||||
{
|
||||
agentVersion = PrintApiClient.AgentVersion,
|
||||
capabilities = s_capabilities,
|
||||
lastAppliedRevision = 0,
|
||||
lastSettingsRevision = 0,
|
||||
limits = RealtimeSettings.ClientLimits
|
||||
}, token: token);
|
||||
RealtimeEnvelope welcome = await messages.Reader.ReadAsync(token);
|
||||
Require(string.Equals(welcome.Type, "WELCOME", StringComparison.Ordinal), "welcome");
|
||||
result.Settings = welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;
|
||||
result.Settings.Validate();
|
||||
channel.UpdateSettings(result.Settings);
|
||||
await channel.SendAsync("STATE_APPLIED", new
|
||||
{
|
||||
lastAppliedRevision = welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),
|
||||
lastSettingsRevision = result.Settings.Revision
|
||||
}, acknowledged: true, token: token);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Lifetime.CancelAsync();
|
||||
try
|
||||
{
|
||||
await Reader;
|
||||
}
|
||||
catch(Exception e) when(e is OperationCanceledException or IOException or WebSocketException)
|
||||
{
|
||||
// Cancelling the owned reader normally ends it with one of these; disposal must still finish.
|
||||
}
|
||||
Channel.Dispose();
|
||||
Lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
await all;
|
||||
Require(lanes.Sum(l=>l.Printer.Count)==total,"exact fake submission count");
|
||||
Require(lanes.All(l=>l.Journal.Pending().Count==0),"no unacknowledged receipts");
|
||||
stage="logout";foreach(var lane in lanes)await lane.Client.SignOutAsync(timeout.Token);
|
||||
double[] latencies=lanes.SelectMany(l=>l.Latencies).Order().ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new {
|
||||
success=true,jobs=total,idle,tenants=2,devices=8,elapsedSeconds=timer.Elapsed.TotalSeconds,jobsPerSecond=total/timer.Elapsed.TotalSeconds,
|
||||
peakClientWorkingSetBytes=peak,p50EnqueueToAckMillis=latencies[total/2],p95EnqueueToAckMillis=latencies[(int)(total*0.95)],p99EnqueueToAckMillis=latencies[(int)(total*0.99)],
|
||||
realCoreWorker=true,fakePrinterBackend=true,fixtureJournal=true,serverReceiptsDurable=true,crossTenantProofRefused=denied,
|
||||
submissionReplays=idle?0:400,receiptReplays=idle?0:400,notificationToClaimMillis=lanes.SelectMany(l=>l.ClaimLatency).ToArray(),notificationToDownloadCompletedMillis=lanes.SelectMany(l=>l.DownloadLatency).ToArray(),periodicJobPolling=false,transport="HTTP/WS loopback; no TLS qualification",realPrinterCalls=0,
|
||||
runtime=Environment.Version.ToString(),coreVersion=typeof(PrintWorker).Assembly.GetName().Version?.ToString()
|
||||
},new JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");return 0;
|
||||
} catch(Exception error) {
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new{success=false,stage,error=error.GetType().Name,reason=error is InvalidDataException?error.Message:null,completed=lanes.Sum(l=>l.Printer.Count)}));
|
||||
Console.Error.WriteLine("FAIL "+stage+": "+error.GetType().Name);return 1;
|
||||
} finally {
|
||||
foreach(var lane in lanes)await lane.DisposeAsync();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"done"),"done");
|
||||
}
|
||||
}
|
||||
static void Require(bool ok,string reason){if(!ok)throw new InvalidDataException(reason);}
|
||||
sealed class Lane: IAsyncDisposable {
|
||||
public QqqSessionClient Client{get;} public Connection Connection{get;}
|
||||
public FakePrinter Printer{get;}=new(); public Journal Journal{get;} public List<double> Latencies{get;}=[];
|
||||
readonly PrintApiClient api;readonly PrintWorker worker; readonly int target; readonly string root; long wakeAt; public List<double> ClaimLatency{get;}=[]; public List<double> DownloadLatency{get;}=[];
|
||||
public Lane(QqqSessionClient client,Connection connection,string folder,int target,string root) {
|
||||
Client=client;Connection=connection;this.target=target;this.root=root;Printer.OnDownload=()=>DownloadLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);Directory.CreateDirectory(folder);Journal=new(folder);
|
||||
api=PrintApiClient.ForSession(client).WithRealtime(connection.Channel,connection.Settings);
|
||||
worker=new PrintWorker(api,Printer,Journal,options:new PrintWorkerOptions{Settings=connection.Settings,StagingDirectory=Path.Combine(folder,"staging"),SynchronizeInventory=false,RecoverIncomplete=false,ActiveJobChanged=job=>{if(job is not null)ClaimLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);}});
|
||||
}
|
||||
public async Task Run(CancellationToken token) {
|
||||
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task ping=Liveness(stop.Token);
|
||||
try {
|
||||
while(Printer.Count<target) {
|
||||
wakeAt=await Connection.Wakes.Reader.ReadAsync(token);
|
||||
while(Printer.Count<target && await worker.RunOnceAsync(token)) {
|
||||
Require(Journal.Last.Event.Status=="SPOOLED","synthetic job terminal status");
|
||||
Latencies.Add((DateTimeOffset.UtcNow-DateTimeOffset.Parse(Journal.LastJob!.CreatedAt,CultureInfo.InvariantCulture)).TotalMilliseconds);
|
||||
await File.WriteAllTextAsync(Path.Combine(root,"completed-"+Interlocked.Increment(ref Completed)),"acknowledged",token);
|
||||
if(Printer.Count%25==0)await api.EventAsync(Journal.Last.JobId,Journal.Last.Event,token);
|
||||
}
|
||||
}
|
||||
} finally {await stop.CancelAsync();try{await ping;}catch(OperationCanceledException){}}
|
||||
}
|
||||
async Task Liveness(CancellationToken token) {
|
||||
using var tick=new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
|
||||
while(await tick.WaitForNextTickAsync(token))await Connection.Channel.SendAsync("PING",new{},token:token);
|
||||
}
|
||||
public async ValueTask DisposeAsync(){api.Dispose();await Connection.DisposeAsync();Client.Dispose();Journal.Dispose();}
|
||||
}
|
||||
sealed class FakePrinter:IFilePrinterBackend {
|
||||
readonly HashSet<string> seen=new(StringComparer.Ordinal);public int Count=>seen.Count; public Action? OnDownload{get;set;}
|
||||
public IReadOnlyList<PrinterInfo> Discover()=>[new("synthetic","No physical printer","fixture","none","READY",["ZPL"])];
|
||||
public async Task<string> PrintFileAsync(PrintJob job,string path,CancellationToken token) {OnDownload?.Invoke();return await PrintAsync(job,await File.ReadAllBytesAsync(path,token),token);}
|
||||
public Task<string> PrintAsync(PrintJob job,byte[] content,CancellationToken token) {
|
||||
Require(content.AsSpan().SequenceEqual(Expected),"downloaded bytes exact");Require(seen.Add(job.JobId),"no duplicate physical action");return Task.FromResult("synthetic-"+Count);
|
||||
}
|
||||
}
|
||||
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
|
||||
sealed class Journal: IJobJournal,IDisposable {
|
||||
readonly Dictionary<string,string> states=new(StringComparer.Ordinal);readonly Dictionary<string,(string,PrintEvent)> pending=new(StringComparer.Ordinal);readonly StreamWriter log;
|
||||
public PrintJob? LastJob{get;private set;}public (string JobId,PrintEvent Event) Last{get;private set;}
|
||||
public Journal(string folder){log=new StreamWriter(new FileStream(Path.Combine(folder,"synthetic-receipts.jsonl"),FileMode.CreateNew,FileAccess.Write,FileShare.Read)){AutoFlush=true};}
|
||||
public void TrackJob(PrintJob job,string? printer){LastJob=job;}
|
||||
public string? GetState(string id)=>states.GetValueOrDefault(id);
|
||||
public void Record(string id,PrintEvent evt){log.WriteLine(JsonSerializer.Serialize(new{jobId=id,@event=evt}));states[id]=evt.Status;pending.Add(evt.EventId,(id,evt));Last=(id,evt);}
|
||||
public IReadOnlyList<(string JobId,PrintEvent Event)> Pending()=>pending.Values.ToList();
|
||||
public void Acknowledge(string id){pending.Remove(id);}
|
||||
public IReadOnlyList<(string JobId,string State)> Incomplete()=>[];
|
||||
public void Dispose()=>log.Dispose();
|
||||
}
|
||||
sealed class Connection:IAsyncDisposable {
|
||||
public required PrintingRealtimeChannel Channel{get;init;}public required Task Reader{get;init;}public required CancellationTokenSource Lifetime{get;init;}
|
||||
public required Channel<RealtimeEnvelope> Messages{get;init;}public required Channel<long> Wakes{get;init;}public RealtimeSettings Settings{get;private set;}=new();
|
||||
public static async Task<Connection> Open(QqqSessionClient client,CancellationToken token) {
|
||||
var lifetime=CancellationTokenSource.CreateLinkedTokenSource(token);WebSocket socket=await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20),TimeSpan.FromSeconds(60),token);
|
||||
var channel=new PrintingRealtimeChannel(socket,new RealtimeSettings());var messages=System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
|
||||
var wakes=System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1){FullMode=BoundedChannelFullMode.DropWrite});
|
||||
Task reader=channel.RunAsync(e=>{
|
||||
if(e.Type=="JOB_AVAILABLE"){wakes.Writer.TryWrite(Stopwatch.GetTimestamp());return;}
|
||||
if(e.Type is "SETTINGS" or "DESIRED_STATE") {long revision=e.Payload.GetProperty("revision").GetInt64();if(revision!=0)throw new InvalidDataException("Unexpected policy change");return;}
|
||||
if(e.Type is "PONG" or "ACK")return;
|
||||
if(!messages.Writer.TryWrite(e))throw new IOException("Fixture envelope inbox full");
|
||||
},lifetime.Token);
|
||||
_=reader.ContinueWith(t=>{wakes.Writer.TryComplete(t.Exception);messages.Writer.TryComplete(t.Exception);},TaskScheduler.Default);
|
||||
var result=new Connection{Channel=channel,Reader=reader,Lifetime=lifetime,Messages=messages,Wakes=wakes};
|
||||
await channel.SendAsync("HELLO",new{agentVersion=PrintApiClient.AgentVersion,capabilities=new[]{"realtime-v1","REFRESH_INVENTORY","CANCEL_SPOOL"},lastAppliedRevision=0,lastSettingsRevision=0,limits=RealtimeSettings.ClientLimits},token:token);
|
||||
RealtimeEnvelope welcome=await messages.Reader.ReadAsync(token);Require(welcome.Type=="WELCOME","welcome");
|
||||
result.Settings=welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;result.Settings.Validate();channel.UpdateSettings(result.Settings);
|
||||
await channel.SendAsync("STATE_APPLIED",new{lastAppliedRevision=welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),lastSettingsRevision=result.Settings.Revision},acknowledged:true,token:token);return result;
|
||||
}
|
||||
public async ValueTask DisposeAsync(){await Lifetime.CancelAsync();try{await Reader;}catch(Exception e)when(e is OperationCanceledException or IOException or WebSocketException){}Channel.Dispose();Lifetime.Dispose();}
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -1,15 +1,12 @@
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core">
|
||||
<HintPath>$(CoreAssemblyPath)</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,10 +1,14 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies production Core discovery rules before any fixture credentials are sent.</summary>
|
||||
internal static class WorkloadDiscovery
|
||||
{
|
||||
/// <summary>Require a compatible realtime extension after strict production discovery validation.</summary>
|
||||
internal static async Task<QqqApiDiscovery> DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -19,8 +19,11 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -41,17 +44,30 @@ import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/** Docker-only developer host. Compose publishes its port to host loopback only. */
|
||||
|
||||
/***************************************************************************
|
||||
** Docker-only developer host. Compose publishes its port to host loopback only.
|
||||
***************************************************************************/
|
||||
public final class DevHost extends PrintingLocalHost
|
||||
{
|
||||
private static final String TABLE = "printing_documents";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Configure the isolated developer database and synthetic account from its private environment.
|
||||
***************************************************************************/
|
||||
private DevHost() throws Exception
|
||||
{
|
||||
super(new PrintingLocalSettings("jdbc:h2:file:/data/printing", "sa", required("QQQ_DATABASE_PASSWORD"), "development",
|
||||
Integer.parseInt(required("QQQ_HTTP_PORT")), required("QQQ_MANAGEMENT_TOKEN"), "developer", required("QQQ_ADMIN_PASSWORD")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for owned storage and launch the loopback-published developer host.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
waitStorage();
|
||||
@@ -73,6 +89,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Add the isolated MinIO-backed document table and make sure its bucket exists.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
@@ -87,19 +108,17 @@ public final class DevHost extends PrintingLocalHost
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE).withFileNameFieldName("id").withContentsFieldName("content"))
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
var storage = S3ClientFactory.getClient(backend);
|
||||
try
|
||||
{
|
||||
try
|
||||
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
catch(S3Exception missing)
|
||||
{
|
||||
if(missing.statusCode() != 404)
|
||||
{
|
||||
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
catch(S3Exception missing)
|
||||
{
|
||||
if(missing.statusCode() != 404)
|
||||
{
|
||||
throw missing;
|
||||
}
|
||||
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
|
||||
throw missing;
|
||||
}
|
||||
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
PrintingQBitConfig config = instance.getQBits().values().stream().map(value -> value.getConfig())
|
||||
.filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
|
||||
@@ -108,6 +127,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound startup retries to the owned MinIO health endpoint.
|
||||
***************************************************************************/
|
||||
private static void waitStorage() throws Exception
|
||||
{
|
||||
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build())
|
||||
@@ -123,7 +147,7 @@ public final class DevHost extends PrintingLocalHost
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException notReady)
|
||||
catch(IOException notReady)
|
||||
{
|
||||
// Only the fixture startup window retries; application work is never retried here.
|
||||
}
|
||||
@@ -133,6 +157,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a required fixture setting without exposing its value.
|
||||
***************************************************************************/
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
|
||||
@@ -19,202 +19,423 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import com.kingsrook.qbits.printing.*;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import org.json.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.printing.DriverDataSource;
|
||||
import com.kingsrook.qbits.printing.PrintingException;
|
||||
import com.kingsrook.qbits.printing.PrintingService;
|
||||
import com.kingsrook.qbits.printing.PrintingTestDocuments;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.*;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/** Non-listening synthetic harness. Never routes work to a Windows device. */
|
||||
public final class S3PrintingQualification {
|
||||
private static final String TABLE = "printing_documents";
|
||||
private static final JSONArray CHECKS = new JSONArray();
|
||||
private static final JSONArray DOCUMENTS = new JSONArray();
|
||||
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
|
||||
private static DriverDataSource database;
|
||||
private static S3Client s3;
|
||||
private static String bucket;
|
||||
private static String otherBucket;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path output = Path.of(args[0]).toAbsolutePath();
|
||||
Files.createDirectories(output);
|
||||
String run = args[2];
|
||||
bucket = "printing-fixture-" + run;
|
||||
otherBucket = "printing-canary-" + run;
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
|
||||
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
QInstance instance = new QInstance();
|
||||
// Required metadata only: this harness opens no HTTP endpoint or user session.
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
|
||||
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
|
||||
try {
|
||||
s3 = S3ClientFactory.getClient(backend);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
|
||||
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
|
||||
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
|
||||
PrintingService a = service(instance, "fixture-tenant-a");
|
||||
PrintingService b = service(instance, "fixture-tenant-b");
|
||||
String deviceA = enroll(a);
|
||||
String deviceB = enroll(b);
|
||||
byte[] pdf = PrintingTestDocuments.pdf();
|
||||
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] large = new byte[6 * 1024 * 1024 + 71];
|
||||
Arrays.fill(large, (byte)'A');
|
||||
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
|
||||
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
|
||||
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
|
||||
finish(a, deviceA, pdfJob, "SPOOLED");
|
||||
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
|
||||
finish(a, deviceA, zplJob, "SPOOLED");
|
||||
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
|
||||
finish(a, deviceA, largeJob, "SPOOLED");
|
||||
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
|
||||
finish(b, deviceB, foreignJob, "SPOOLED");
|
||||
String foreignRef = reference(foreignJob);
|
||||
String ownRef = reference(pdfJob);
|
||||
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
|
||||
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
|
||||
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
|
||||
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
|
||||
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
|
||||
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
|
||||
finish(a, deviceA, failed, "FAILED");
|
||||
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
|
||||
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
|
||||
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
|
||||
String old = Instant.now().minusSeconds(400L * 86400).toString();
|
||||
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active)) {
|
||||
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
|
||||
/***************************************************************************
|
||||
** Non-listening synthetic harness. Never routes work to a Windows device.
|
||||
***************************************************************************/
|
||||
public final class S3PrintingQualification
|
||||
{
|
||||
private static final String TABLE = "printing_documents";
|
||||
private static final JSONArray CHECKS = new JSONArray();
|
||||
private static final JSONArray DOCUMENTS = new JSONArray();
|
||||
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
|
||||
private static DriverDataSource database;
|
||||
private static S3Client s3;
|
||||
private static String bucket;
|
||||
private static String otherBucket;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exercise real storage operations against dedicated synthetic buckets without dispatching to a printer.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path output = Path.of(args[0]).toAbsolutePath();
|
||||
Files.createDirectories(output);
|
||||
String run = args[2];
|
||||
bucket = "printing-fixture-" + run;
|
||||
otherBucket = "printing-canary-" + run;
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
|
||||
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
QInstance instance = new QInstance();
|
||||
// Required metadata only: this harness opens no HTTP endpoint or user session.
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
|
||||
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
|
||||
try
|
||||
{
|
||||
s3 = S3ClientFactory.getClient(backend);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
|
||||
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
|
||||
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
|
||||
PrintingService a = service(instance, "fixture-tenant-a");
|
||||
PrintingService b = service(instance, "fixture-tenant-b");
|
||||
String deviceA = enroll(a);
|
||||
String deviceB = enroll(b);
|
||||
byte[] pdf = PrintingTestDocuments.pdf();
|
||||
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] large = new byte[6 * 1024 * 1024 + 71];
|
||||
Arrays.fill(large, (byte) 'A');
|
||||
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
|
||||
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
|
||||
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
|
||||
finish(a, deviceA, pdfJob, "SPOOLED");
|
||||
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
|
||||
finish(a, deviceA, zplJob, "SPOOLED");
|
||||
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
|
||||
finish(a, deviceA, largeJob, "SPOOLED");
|
||||
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
|
||||
finish(b, deviceB, foreignJob, "SPOOLED");
|
||||
String foreignRef = reference(foreignJob);
|
||||
String ownRef = reference(pdfJob);
|
||||
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
|
||||
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
|
||||
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
|
||||
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
|
||||
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
|
||||
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
|
||||
finish(a, deviceA, failed, "FAILED");
|
||||
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
|
||||
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
|
||||
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
|
||||
String old = Instant.now().minusSeconds(400L * 86400).toString();
|
||||
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active))
|
||||
{
|
||||
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
|
||||
}
|
||||
int before = objectCount();
|
||||
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
|
||||
check(objectCount() == before - 2, "object count reflects exact two removals");
|
||||
missing(OBJECT_KEYS.get(ownRef));
|
||||
missing(OBJECT_KEYS.get(reference(largeJob)));
|
||||
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
|
||||
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
|
||||
check(objectCount() == before - 2, "post-retention replay creates no object");
|
||||
for(String id : List.of(zplJob, foreignJob, failed, unknown, active))
|
||||
{
|
||||
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
|
||||
"protected object retained: " + status(id));
|
||||
}
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
|
||||
check(a.runRetention() == 0, "repeated cleanup is idempotent");
|
||||
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
|
||||
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
|
||||
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
|
||||
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
|
||||
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
|
||||
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
|
||||
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
|
||||
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
|
||||
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
|
||||
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
|
||||
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
|
||||
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
|
||||
}
|
||||
int before = objectCount();
|
||||
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
|
||||
check(objectCount() == before - 2, "object count reflects exact two removals");
|
||||
missing(OBJECT_KEYS.get(ownRef));
|
||||
missing(OBJECT_KEYS.get(reference(largeJob)));
|
||||
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
|
||||
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
|
||||
check(objectCount() == before - 2, "post-retention replay creates no object");
|
||||
for(String id : List.of(zplJob, foreignJob, failed, unknown, active)) {
|
||||
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
|
||||
"protected object retained: " + status(id));
|
||||
finally
|
||||
{
|
||||
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
|
||||
S3ClientFactory.clearCache();
|
||||
QContext.clear();
|
||||
}
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
|
||||
check(a.runRetention() == 0, "repeated cleanup is idempotent");
|
||||
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
|
||||
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
|
||||
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
|
||||
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
|
||||
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
|
||||
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
|
||||
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
|
||||
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
|
||||
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
|
||||
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
|
||||
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
|
||||
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
|
||||
} finally {
|
||||
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
|
||||
S3ClientFactory.clearCache();
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
private static PrintingService service(QInstance instance, String tenant) throws Exception {
|
||||
PrintingService service = new PrintingService(database, tenant);
|
||||
service.initialize(); service.configureStorage(instance, TABLE); return service;
|
||||
}
|
||||
private static String enroll(PrintingService service) throws Exception {
|
||||
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
|
||||
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
|
||||
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
|
||||
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
|
||||
service.enablePrinter(id, "synthetic-never-dispatched", true); return id;
|
||||
}
|
||||
private static JSONObject request(String device, String key, String format, byte[] bytes) {
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
|
||||
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
}
|
||||
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception {
|
||||
JSONObject request = request(device, key, format, bytes);
|
||||
String id = service.submit(request).getString("jobId");
|
||||
int count = objectCount();
|
||||
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
|
||||
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
|
||||
byte[] actual = service.content(device, id);
|
||||
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
|
||||
String ref = reference(id);
|
||||
String actualKey = objectKey(ref);
|
||||
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
|
||||
check(head.contentLength() == bytes.length, key + " object length exact");
|
||||
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
|
||||
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1, id); try(var rows = statement.executeQuery()) { check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy"); }
|
||||
}
|
||||
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
|
||||
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
|
||||
return id;
|
||||
}
|
||||
private static void finish(PrintingService service, String device, String id, String status) throws Exception {
|
||||
for(String phase : List.of("RECEIVED", "SUBMITTING", status)) service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
|
||||
}
|
||||
private static String sha(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
private static int objectCount() { return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size(); }
|
||||
private static String objectKey(String reference) {
|
||||
String known = OBJECT_KEYS.get(reference);
|
||||
if(known != null) return known;
|
||||
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
|
||||
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
|
||||
if(matches.size() != 1) throw new AssertionError("Expected exactly one synthetic object for stored reference");
|
||||
String key = matches.getFirst();
|
||||
String prefix = key.substring(0, key.length() - reference.length());
|
||||
if(!prefix.isEmpty() && !prefix.matches("\\\\+")) throw new AssertionError("Unexpected physical S3 key prefix");
|
||||
OBJECT_KEYS.put(reference, key);
|
||||
return key;
|
||||
}
|
||||
private static String reference(String id) throws Exception { return field(id, "content_reference"); }
|
||||
private static String status(String id) throws Exception { return field(id, "status"); }
|
||||
private static String field(String id, String field) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1,id); try(var rows=statement.executeQuery()) { if(!rows.next()) throw new AssertionError("Missing synthetic job"); return rows.getString(1); }
|
||||
}
|
||||
}
|
||||
private static void execute(String sql, String... args) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement(sql)) { for(int i=0; i<args.length; i++) statement.setString(i+1,args[i]); statement.executeUpdate(); }
|
||||
}
|
||||
private static void check(boolean pass, String label) { if(!pass) throw new AssertionError(label); CHECKS.put(label); }
|
||||
private static void missing(String ref) {
|
||||
try { s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build()); throw new AssertionError("Expected removed object"); }
|
||||
catch(S3Exception exception) { check(exception.statusCode()==404,"eligible object absent from S3 after cleanup"); }
|
||||
}
|
||||
@FunctionalInterface private interface Checked { void run() throws Exception; }
|
||||
private static void expectStatus(int status, Checked operation, String label) throws Exception {
|
||||
try { operation.run(); throw new AssertionError(label); } catch(PrintingException exception) { check(exception.getStatus()==status,label); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Initialize a tenant-scoped service using the shared synthetic storage metadata.
|
||||
***************************************************************************/
|
||||
private static PrintingService service(QInstance instance, String tenant) throws Exception
|
||||
{
|
||||
PrintingService service = new PrintingService(database, tenant);
|
||||
service.initialize();
|
||||
service.configureStorage(instance, TABLE);
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Create and approve a synthetic storage-only destination.
|
||||
***************************************************************************/
|
||||
private static String enroll(PrintingService service) throws Exception
|
||||
{
|
||||
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
|
||||
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
|
||||
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
|
||||
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
|
||||
service.enablePrinter(id, "synthetic-never-dispatched", true);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build a repeatable submission containing synthetic document bytes.
|
||||
***************************************************************************/
|
||||
private static JSONObject request(String device, String key, String format, byte[] bytes)
|
||||
{
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
|
||||
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify submission replay, download integrity, and the actual external object representation.
|
||||
***************************************************************************/
|
||||
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception
|
||||
{
|
||||
JSONObject request = request(device, key, format, bytes);
|
||||
String id = service.submit(request).getString("jobId");
|
||||
int count = objectCount();
|
||||
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
|
||||
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
|
||||
byte[] actual = service.content(device, id);
|
||||
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
|
||||
String ref = reference(id);
|
||||
String actualKey = objectKey(ref);
|
||||
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
|
||||
check(head.contentLength() == bytes.length, key + " object length exact");
|
||||
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
|
||||
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, id);
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy");
|
||||
}
|
||||
}
|
||||
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
|
||||
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record synthetic lifecycle events without invoking a physical spooler.
|
||||
***************************************************************************/
|
||||
private static void finish(PrintingService service, String device, String id, String status) throws Exception
|
||||
{
|
||||
for(String phase : List.of("RECEIVED", "SUBMITTING", status))
|
||||
{
|
||||
service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compute the document digest used for round-trip assertions.
|
||||
***************************************************************************/
|
||||
private static String sha(byte[] bytes) throws Exception
|
||||
{
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Count objects in the dedicated synthetic bucket.
|
||||
***************************************************************************/
|
||||
private static int objectCount()
|
||||
{
|
||||
return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve and cache the physical key corresponding to a persisted document reference.
|
||||
***************************************************************************/
|
||||
private static String objectKey(String reference)
|
||||
{
|
||||
String known = OBJECT_KEYS.get(reference);
|
||||
if(known != null)
|
||||
{
|
||||
return known;
|
||||
}
|
||||
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
|
||||
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
|
||||
if(matches.size() != 1)
|
||||
{
|
||||
throw new AssertionError("Expected exactly one synthetic object for stored reference");
|
||||
}
|
||||
String key = matches.getFirst();
|
||||
String prefix = key.substring(0, key.length() - reference.length());
|
||||
if(!prefix.isEmpty() && !prefix.matches("\\\\+"))
|
||||
{
|
||||
throw new AssertionError("Unexpected physical S3 key prefix");
|
||||
}
|
||||
OBJECT_KEYS.put(reference, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the accepted external reference for a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String reference(String id) throws Exception
|
||||
{
|
||||
return field(id, "content_reference");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the lifecycle state of a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String status(String id) throws Exception
|
||||
{
|
||||
return field(id, "status");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a fixture-selected field from a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String field(String id, String field) throws Exception
|
||||
{
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, id);
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
throw new AssertionError("Missing synthetic job");
|
||||
}
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Apply a bounded synthetic fixture adjustment with bound values.
|
||||
***************************************************************************/
|
||||
private static void execute(String sql, String... args) throws Exception
|
||||
{
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement(sql))
|
||||
{
|
||||
for(int i = 0; i < args.length; i++)
|
||||
{
|
||||
statement.setString(i + 1, args[i]);
|
||||
}
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record a successful assertion or stop qualification immediately.
|
||||
***************************************************************************/
|
||||
private static void check(boolean pass, String label)
|
||||
{
|
||||
if(!pass)
|
||||
{
|
||||
throw new AssertionError(label);
|
||||
}
|
||||
CHECKS.put(label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify an eligible object is absent after retention.
|
||||
***************************************************************************/
|
||||
private static void missing(String ref)
|
||||
{
|
||||
try
|
||||
{
|
||||
s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build());
|
||||
throw new AssertionError("Expected removed object");
|
||||
}
|
||||
catch(S3Exception exception)
|
||||
{
|
||||
check(exception.statusCode() == 404, "eligible object absent from S3 after cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Operation that can surface a checked storage or service failure.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Checked
|
||||
{
|
||||
/***************************************************************************
|
||||
** Execute an operation whose expected HTTP-domain failure is checked by the caller.
|
||||
***************************************************************************/
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Require the exact domain rejection while allowing unrelated failures to surface.
|
||||
***************************************************************************/
|
||||
private static void expectStatus(int status, Checked operation, String label) throws Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
operation.run();
|
||||
throw new AssertionError(label);
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
check(exception.getStatus() == status, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
-83
@@ -19,94 +19,229 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
import java.nio.file.*;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qqq.middleware.javalin.*;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import io.javalin.Javalin;
|
||||
import org.json.*;
|
||||
/** Fixture only: real checkout host, synthetic SDK producer, no physical printer. */
|
||||
public final class WorkloadFixture {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path dir=Path.of(args[0]).toAbsolutePath();
|
||||
if(!dir.getFileName().toString().startsWith("run-"))throw new IllegalArgumentException("Owned run directory required");
|
||||
String jdbc="jdbc:h2:file:"+dir.resolve("synthetic").toString().replace('\\','/')+";DB_CLOSE_DELAY=-1";
|
||||
String password="synthetic-"+UUID.randomUUID();
|
||||
List<QApplicationLauncher> launches=new ArrayList<>(); List<PrintingService> services=new ArrayList<>();
|
||||
List<String> devices=new ArrayList<>(); JSONArray inputs=new JSONArray();
|
||||
boolean idle=args.length>1 && args[1].equals("idle"); int total=idle?40:10000; long started=System.nanoTime(); int exit=1;
|
||||
try {
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
PrintingLocalHost host=new PrintingLocalHost(new PrintingLocalSettings(jdbc,"sa","","tenant-"+tenant,0,"fixture-only-management-token-123456","operator",password));
|
||||
AtomicReference<Javalin> app=new AtomicReference<>();
|
||||
QApplicationLauncher launch=QApplicationLauncher.run(host,new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server->{host.configure(server,false);server.withJavalinConfigurationCustomizer(app::set);}));
|
||||
launches.add(launch);
|
||||
PrintingService service=launch.getQInstance().getQBits().values().stream().map(v->v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
|
||||
services.add(service);
|
||||
for(int lane=0;lane<4;lane++) {
|
||||
JSONObject enrolled=service.enrollForUser(service.createEnrollment("default","Synthetic lane").put("deviceName","Synthetic lane").put("agentVersion","qualification"),"operator");
|
||||
String device=enrolled.getString("deviceId");devices.add(device);
|
||||
service.inventory(device,new JSONArray().put(new JSONObject().put("printerId","synthetic").put("name","No physical printer").put("status","READY").put("formats",new JSONArray().put("ZPL"))));service.enablePrinter(device,"synthetic",true);
|
||||
inputs.put(new JSONObject().put("origin","http://127.0.0.1:"+app.get().port()+"/").put("username","operator").put("password",password).put("tenant","tenant-"+tenant).put("deviceId",device).put("deviceToken",enrolled.getString("deviceToken")));
|
||||
}
|
||||
}
|
||||
// Only synthetic credentials inside the already protected run directory.
|
||||
Files.writeString(dir.resolve("input.json"),new JSONObject().put("lanes",inputs).put("idle",idle).put("jobsPerLane",total/8).toString());
|
||||
await(dir.resolve("ready"),Duration.ofMinutes(2));
|
||||
byte[] payload=("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+" ".repeat(980)+"^XZ").getBytes(StandardCharsets.US_ASCII);
|
||||
String encoded=Base64.getEncoder().encodeToString(payload);
|
||||
long producing=System.nanoTime();
|
||||
if(idle) { for(int job=0;job<total;job++) { int lane=job%8; PrintingService service=services.get(lane/4); JSONObject request=new JSONObject().put("deviceId",devices.get(lane)).put("printerId","synthetic").put("title","Synthetic idle workload").put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","idle-"+job); service.submit(request); await(dir.resolve("completed-"+(job+1)),Duration.ofSeconds(30)); Thread.sleep(200); } } else try(var executor=Executors.newFixedThreadPool(8)) {
|
||||
List<Future<?>> futures=new ArrayList<>();
|
||||
for(int lane=0;lane<8;lane++) { final int index=lane;
|
||||
futures.add(executor.submit(()->{try{
|
||||
PrintingService service=services.get(index/4);
|
||||
for(int job=0;job<1250;job++) {
|
||||
JSONObject request=new JSONObject().put("deviceId",devices.get(index)).put("printerId","synthetic").put("title","Synthetic workload "+job).put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","lane-"+index+"-"+job);
|
||||
JSONObject submitted=service.submit(request);
|
||||
if(job%25==0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))throw new IllegalStateException("Replay duplicated job");
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fixture only: real checkout host, synthetic SDK producer, no physical printer.
|
||||
***************************************************************************/
|
||||
public final class WorkloadFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Drive two synthetic tenants through real network workers and verify durable receipts and accounting.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path dir = Path.of(args[0]).toAbsolutePath();
|
||||
if(!dir.getFileName().toString().startsWith("run-"))
|
||||
{
|
||||
throw new IllegalArgumentException("Owned run directory required");
|
||||
}
|
||||
}catch(Exception e){throw new RuntimeException(e);}}));
|
||||
}
|
||||
for(var future:futures)future.get(15,TimeUnit.MINUTES);
|
||||
String jdbc = "jdbc:h2:file:" + dir.resolve("synthetic").toString().replace('\\', '/') + ";DB_CLOSE_DELAY=-1";
|
||||
String password = "synthetic-" + UUID.randomUUID();
|
||||
List<QApplicationLauncher> launches = new ArrayList<>();
|
||||
List<PrintingService> services = new ArrayList<>();
|
||||
List<String> devices = new ArrayList<>();
|
||||
JSONArray inputs = new JSONArray();
|
||||
boolean idle = args.length > 1 && args[1].equals("idle");
|
||||
int total = idle ? 40 : 10000;
|
||||
long started = System.nanoTime();
|
||||
int exit = 1;
|
||||
try
|
||||
{
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "tenant-" + tenant, 0, "fixture-only-management-token-123456", "operator", password));
|
||||
AtomicReference<Javalin> app = new AtomicReference<>();
|
||||
QApplicationLauncher launch = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
|
||||
{
|
||||
host.configure(server, false);
|
||||
server.withJavalinConfigurationCustomizer(app::set);
|
||||
}));
|
||||
launches.add(launch);
|
||||
PrintingService service = launch.getQInstance().getQBits().values().stream().map(v -> v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
|
||||
services.add(service);
|
||||
for(int lane = 0; lane < 4; lane++)
|
||||
{
|
||||
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Synthetic lane").put("deviceName", "Synthetic lane").put("agentVersion", "qualification"), "operator");
|
||||
String device = enrolled.getString("deviceId");
|
||||
devices.add(device);
|
||||
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "synthetic").put("name", "No physical printer").put("status", "READY").put("formats", new JSONArray().put("ZPL"))));
|
||||
service.enablePrinter(device, "synthetic", true);
|
||||
inputs.put(new JSONObject().put("origin", "http://127.0.0.1:" + app.get().port() + "/").put("username", "operator").put("password", password).put("tenant", "tenant-" + tenant).put("deviceId", device).put("deviceToken", enrolled.getString("deviceToken")));
|
||||
}
|
||||
}
|
||||
// Only synthetic credentials inside the already protected run directory.
|
||||
Files.writeString(dir.resolve("input.json"), new JSONObject().put("lanes", inputs).put("idle", idle).put("jobsPerLane", total / 8).toString());
|
||||
await(dir.resolve("ready"), Duration.ofMinutes(2));
|
||||
byte[] payload = ("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + " ".repeat(980) + "^XZ").getBytes(StandardCharsets.US_ASCII);
|
||||
String encoded = Base64.getEncoder().encodeToString(payload);
|
||||
long producing = System.nanoTime();
|
||||
if(idle)
|
||||
{
|
||||
for(int job = 0; job < total; job++)
|
||||
{
|
||||
int lane = job % 8;
|
||||
PrintingService service = services.get(lane / 4);
|
||||
JSONObject request = new JSONObject().put("deviceId", devices.get(lane)).put("printerId", "synthetic").put("title", "Synthetic idle workload").put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "idle-" + job);
|
||||
service.submit(request);
|
||||
await(dir.resolve("completed-" + (job + 1)), Duration.ofSeconds(30));
|
||||
Thread.sleep(200);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try(var executor = Executors.newFixedThreadPool(8))
|
||||
{
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for(int lane = 0; lane < 8; lane++)
|
||||
{
|
||||
final int index = lane;
|
||||
futures.add(executor.submit(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
PrintingService service = services.get(index / 4);
|
||||
for(int job = 0; job < 1250; job++)
|
||||
{
|
||||
JSONObject request = new JSONObject().put("deviceId", devices.get(index)).put("printerId", "synthetic").put("title", "Synthetic workload " + job).put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "lane-" + index + "-" + job);
|
||||
JSONObject submitted = service.submit(request);
|
||||
if(job % 25 == 0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))
|
||||
{
|
||||
throw new IllegalStateException("Replay duplicated job");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for(var future : futures)
|
||||
{
|
||||
future.get(15, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
}
|
||||
double producerSeconds = (System.nanoTime() - producing) / 1e9;
|
||||
Files.writeString(dir.resolve("producer-done"), "done");
|
||||
await(dir.resolve("done"), Duration.ofMinutes(15));
|
||||
JSONObject client = new JSONObject(Files.readString(dir.resolve("result.json")));
|
||||
if(!client.getBoolean("success"))
|
||||
{
|
||||
throw new IllegalStateException("Client reported failure");
|
||||
}
|
||||
JSONObject report = new JSONObject().put("success", true).put("jobs", total).put("receipts", total * 3).put("idle", idle).put("producerSeconds", producerSeconds).put("elapsedSeconds", (System.nanoTime() - started) / 1e9).put("realPrinterCalls", 0);
|
||||
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
|
||||
{
|
||||
require(query, "SELECT COUNT(*) FROM printing_job", total);
|
||||
require(query, "SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'", total);
|
||||
require(query, "SELECT COUNT(*) FROM printing_event", total * 3);
|
||||
require(query, "SELECT COUNT(*) FROM printing_document_orphan", 0);
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
require(query, "SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-" + tenant + "'", total / 2);
|
||||
require(query, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-" + tenant + "' AND device_id=''", (total / 2L) * payload.length);
|
||||
}
|
||||
JSONArray cross = new JSONArray();
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
String foreign = services.get(1 - tenant).jobsPage(1, null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
|
||||
try
|
||||
{
|
||||
services.get(tenant).getJob(foreign);
|
||||
throw new IllegalStateException("Foreign job visible");
|
||||
}
|
||||
catch(PrintingException denied)
|
||||
{
|
||||
if(denied.getStatus() != 404)
|
||||
{
|
||||
throw denied;
|
||||
}
|
||||
}
|
||||
cross.put(true);
|
||||
}
|
||||
report.put("tenantIsolation", cross).put("retainedAccountingVerified", true).put("bytesPerDocument", payload.length);
|
||||
}
|
||||
Files.writeString(dir.resolve("server-result.json"), report.toString(2));
|
||||
exit = 0;
|
||||
}
|
||||
catch(Throwable failure)
|
||||
{
|
||||
Files.writeString(dir.resolve("server-error.json"), new JSONObject().put("error", failure.getClass().getName()).put("cause", failure.getCause() == null ? "" : failure.getCause().getClass().getName()).toString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
for(int i = launches.size() - 1; i >= 0; i--)
|
||||
{
|
||||
launches.get(i).stop();
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
|
||||
{
|
||||
query.execute("SHUTDOWN");
|
||||
}
|
||||
Files.writeString(dir.resolve("stopped.json"), new JSONObject().put("normalStop", true).put("exitCode", exit).toString());
|
||||
}
|
||||
System.exit(exit);
|
||||
}
|
||||
double producerSeconds=(System.nanoTime()-producing)/1e9;
|
||||
Files.writeString(dir.resolve("producer-done"),"done");
|
||||
await(dir.resolve("done"),Duration.ofMinutes(15));
|
||||
JSONObject client=new JSONObject(Files.readString(dir.resolve("result.json")));
|
||||
if(!client.getBoolean("success"))throw new IllegalStateException("Client reported failure");
|
||||
JSONObject report=new JSONObject().put("success",true).put("jobs",total).put("receipts",total*3).put("idle",idle).put("producerSeconds",producerSeconds).put("elapsedSeconds",(System.nanoTime()-started)/1e9).put("realPrinterCalls",0);
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_event",total*3);
|
||||
require(query,"SELECT COUNT(*) FROM printing_document_orphan",0);
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-"+tenant+"'",total/2);
|
||||
require(query,"SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-"+tenant+"' AND device_id=''",(total/2L)*payload.length);
|
||||
}
|
||||
JSONArray cross=new JSONArray();for(int tenant=0;tenant<2;tenant++) {
|
||||
String foreign=services.get(1-tenant).jobsPage(1,null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
|
||||
try {services.get(tenant).getJob(foreign);throw new IllegalStateException("Foreign job visible");}catch(PrintingException denied){if(denied.getStatus()!=404)throw denied;}
|
||||
cross.put(true);
|
||||
}
|
||||
report.put("tenantIsolation",cross).put("retainedAccountingVerified",true).put("bytesPerDocument",payload.length);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for a private coordination marker with a monotonic deadline.
|
||||
***************************************************************************/
|
||||
static void await(Path file, Duration limit) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + limit.toNanos();
|
||||
while(!Files.exists(file))
|
||||
{
|
||||
if(System.nanoTime() > deadline)
|
||||
{
|
||||
throw new TimeoutException("Fixture deadline");
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify a synthetic database aggregate without emitting row values.
|
||||
***************************************************************************/
|
||||
static void require(Statement query, String sql, long expected) throws Exception
|
||||
{
|
||||
try(ResultSet rows = query.executeQuery(sql))
|
||||
{
|
||||
if(!rows.next() || rows.getLong(1) != expected)
|
||||
{
|
||||
throw new IllegalStateException("Synthetic count/accounting mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.writeString(dir.resolve("server-result.json"),report.toString(2));exit=0;
|
||||
} catch(Throwable failure) {
|
||||
Files.writeString(dir.resolve("server-error.json"),new JSONObject().put("error",failure.getClass().getName()).put("cause",failure.getCause()==null?"":failure.getCause().getClass().getName()).toString());
|
||||
} finally {
|
||||
for(int i=launches.size()-1;i>=0;i--)launches.get(i).stop();
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()){query.execute("SHUTDOWN");}
|
||||
Files.writeString(dir.resolve("stopped.json"),new JSONObject().put("normalStop",true).put("exitCode",exit).toString());
|
||||
}
|
||||
System.exit(exit);
|
||||
}
|
||||
static void await(Path file,Duration limit)throws Exception {long deadline=System.nanoTime()+limit.toNanos();while(!Files.exists(file)){if(System.nanoTime()>deadline)throw new TimeoutException("Fixture deadline");Thread.sleep(100);}}
|
||||
static void require(Statement query,String sql,long expected)throws Exception {try(ResultSet rows=query.executeQuery(sql)){if(!rows.next()||rows.getLong(1)!=expected)throw new IllegalStateException("Synthetic count/accounting mismatch");}}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fc
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
|
||||
WORKDIR /fixture/dev/native-auth/client
|
||||
COPY dev/native-auth/client .
|
||||
COPY dev/client/WorkloadDiscovery.cs /fixture/dev/client/WorkloadDiscovery.cs
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
|
||||
RUN dotnet format --verify-no-changes --severity warn --no-restore
|
||||
RUN dotnet /out/NativeAuth.dll --check-discovery
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
|
||||
@@ -12,10 +12,11 @@ COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY dev/java dev/java
|
||||
COPY dev/native-auth/java dev/native-auth/java
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Dmaven.repo.local=/candidate-maven -Dqqq.version=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/native-auth/java /fixture-src
|
||||
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/native-auth/java/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Receives the actual HTTPS-proxied callback only inside this test network.</summary>
|
||||
internal sealed class CallbackReceiver : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener = new();
|
||||
|
||||
|
||||
|
||||
private readonly Channel<Uri> _callbacks = Channel.CreateBounded<Uri>(1);
|
||||
|
||||
|
||||
|
||||
private readonly Uri _callback;
|
||||
|
||||
|
||||
|
||||
/// <summary>Receive callbacks only inside the owned native-auth container.</summary>
|
||||
internal CallbackReceiver(Uri callback)
|
||||
{
|
||||
_callback = callback;
|
||||
@@ -21,6 +30,9 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
_ = ReceiveAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Accept only the configured callback path and publish one bounded result.</summary>
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
try
|
||||
@@ -29,10 +41,12 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
{
|
||||
HttpListenerContext context = await _listener.GetContextAsync();
|
||||
Uri? request = context.Request.Url;
|
||||
bool valid = request is not null && request.AbsolutePath == _callback.AbsolutePath
|
||||
&& context.Request.Headers["Host"] == _callback.Authority
|
||||
&& context.Request.Headers["X-Forwarded-Proto"] == "https"
|
||||
&& context.Request.HttpMethod == "GET" && request.Query.Length is > 0 and < 16384;
|
||||
bool valid = request is not null
|
||||
&& string.Equals(request.AbsolutePath, _callback.AbsolutePath, StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.Headers["Host"], _callback.Authority, StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.Headers["X-Forwarded-Proto"], "https", StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.HttpMethod, "GET", StringComparison.Ordinal)
|
||||
&& request.Query.Length is > 0 and < 16384;
|
||||
if(valid)
|
||||
{
|
||||
var actual = new UriBuilder(_callback) { Query = request!.Query.TrimStart('?') };
|
||||
@@ -49,6 +63,13 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Await the actual provider callback under the caller deadline.</summary>
|
||||
internal ValueTask<Uri> ReadAsync(CancellationToken token) => _callbacks.Reader.ReadAsync(token);
|
||||
|
||||
|
||||
|
||||
/// <summary>Close the owned callback listener.</summary>
|
||||
public void Dispose() => _listener.Close();
|
||||
}
|
||||
@@ -1,26 +1,69 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Small bounded fixture transport; no redirects, proxy or response-body diagnostics.</summary>
|
||||
internal sealed class FixtureHttp : IDisposable
|
||||
{
|
||||
/// <summary>Carry only the parsed bounded response and optional redirect location.</summary>
|
||||
internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location);
|
||||
|
||||
|
||||
|
||||
/// <summary>Record fixed classifications without credentials, URLs, or response bodies.</summary>
|
||||
internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery);
|
||||
|
||||
|
||||
|
||||
internal static List<TraceEntry> Trace { get; } = [];
|
||||
internal static string? LastRequestKind { get; private set; }
|
||||
internal static int? LastStatus { get; private set; }
|
||||
|
||||
|
||||
|
||||
internal static string? LastRequestKind
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static int? LastStatus
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
|
||||
|
||||
private readonly CookieContainer _cookies = new();
|
||||
|
||||
|
||||
|
||||
private readonly Uri _origin;
|
||||
|
||||
|
||||
|
||||
private readonly string? _adminToken;
|
||||
|
||||
|
||||
|
||||
/// <summary>Configure bounded same-origin HTTP with redirects and proxies disabled.</summary>
|
||||
internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null)
|
||||
{
|
||||
_origin = origin;
|
||||
@@ -32,9 +75,12 @@ internal sealed class FixtureHttp : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
|
||||
internal async Task<Reply> SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken)
|
||||
{
|
||||
if(uri.Scheme != "https" || uri.GetLeftPart(UriPartial.Authority) != _origin.GetLeftPart(UriPartial.Authority))
|
||||
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) || !string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A fixture request left its configured HTTPS origin.");
|
||||
}
|
||||
@@ -44,7 +90,10 @@ internal sealed class FixtureHttp : IDisposable
|
||||
LastStatus = null;
|
||||
if(_adminToken is not null)
|
||||
{
|
||||
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal)) { throw new InvalidOperationException("Administrative credential scope refused."); }
|
||||
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Administrative credential scope refused.");
|
||||
}
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||
}
|
||||
if(body is not null)
|
||||
@@ -52,36 +101,57 @@ internal sealed class FixtureHttp : IDisposable
|
||||
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority));
|
||||
request.Headers.Referrer = _origin;
|
||||
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "authentik_csrf");
|
||||
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "csrftoken");
|
||||
if(csrf is not null) { request.Headers.Add("X-authentik-CSRF", csrf.Value); }
|
||||
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "authentik_csrf", StringComparison.Ordinal));
|
||||
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "csrftoken", StringComparison.Ordinal));
|
||||
if(csrf is not null)
|
||||
{
|
||||
request.Headers.Add("X-authentik-CSRF", csrf.Value);
|
||||
}
|
||||
}
|
||||
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
|
||||
LastStatus = (int)response.StatusCode;
|
||||
Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location);
|
||||
if(Trace.Count == 32) { Trace.RemoveAt(0); }
|
||||
if(Trace.Count == 32)
|
||||
{
|
||||
Trace.RemoveAt(0);
|
||||
}
|
||||
Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0,
|
||||
location is null ? null : Classify(location, "GET"), location?.Query.Length > 0));
|
||||
string content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
JsonObject? json = response.Content.Headers.ContentType?.MediaType == "application/json" && content.Length != 0
|
||||
JsonObject? json = string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal) && content.Length != 0
|
||||
? JsonNode.Parse(content)?.AsObject() : null;
|
||||
return new Reply(response.StatusCode, json, response.Headers.Location);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
|
||||
internal Task<Reply> SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken)
|
||||
=> SendAsync(method, new Uri(_origin, path), body, cancellationToken);
|
||||
|
||||
private static string Classify(Uri uri, string method) => uri.Host == "callback.printing.internal" ? "callback"
|
||||
|
||||
|
||||
/// <summary>Map an endpoint to a fixed label without exposing its path or query.</summary>
|
||||
private static string Classify(Uri uri, string method) => string.Equals(uri.Host, "callback.printing.internal", StringComparison.Ordinal) ? "callback"
|
||||
: uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization"
|
||||
: uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page"
|
||||
: uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method
|
||||
: "fixture-api";
|
||||
|
||||
|
||||
|
||||
/// <summary>Require the expected status and a parsed JSON object.</summary>
|
||||
internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected)
|
||||
{
|
||||
if(reply.Status != expected || reply.Json is null) { throw new InvalidOperationException("Unexpected fixture API status or document shape."); }
|
||||
if(reply.Status != expected || reply.Json is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected fixture API status or document shape.");
|
||||
}
|
||||
return reply.Json;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Dispose the owned HTTP client and handler.</summary>
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
<!--
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core">
|
||||
<HintPath>$(CoreAssemblyPath)</HintPath>
|
||||
</Reference>
|
||||
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,11 +2,12 @@
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using Printing.Development;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Requires the pinned experimental native grant before provider authentication.</summary>
|
||||
internal static class NativeAuthDiscovery
|
||||
{
|
||||
@@ -14,7 +15,7 @@ internal static class NativeAuthDiscovery
|
||||
internal static async Task DiscoverAsync(QqqSessionClient client, CancellationToken token)
|
||||
{
|
||||
QqqApiDiscovery discovery = await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
|
||||
if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
if(!string.Equals(discovery.ApiVersion, "1.9.0", StringComparison.Ordinal) || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The actual server did not advertise the pinned native-session API.");
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercises the real native probe gate against bounded, credential-free HTTP responses.</summary>
|
||||
internal static class NativeAuthDiscoveryChecks
|
||||
{
|
||||
/// <summary>Accepts compatible printing releases while preserving the exact upstream grant requirement.</summary>
|
||||
private static readonly string[] s_channels = ["api"];
|
||||
|
||||
|
||||
|
||||
/// <summary>Check the native grant and compatible extension using fake discovery responses.</summary>
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string PrintingVersion, string ApiVersion, bool NativeGrant, bool Accepted)[] cases =
|
||||
@@ -42,12 +47,15 @@ internal static class NativeAuthDiscoveryChecks
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
|
||||
{
|
||||
// Expected for incompatible discovery: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidDataException)
|
||||
{
|
||||
// Expected for malformed discovery values: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidOperationException)
|
||||
{
|
||||
// Expected when native-session discovery is absent: the case stays rejected and judged below.
|
||||
}
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
@@ -63,15 +71,24 @@ internal static class NativeAuthDiscoveryChecks
|
||||
private sealed class DiscoveryHandler(string printingVersion, string apiVersion, bool nativeGrant) : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Ensures rejected metadata still traversed the public HTTP discovery boundary.</summary>
|
||||
internal int Requests { get; private set; }
|
||||
internal int Requests
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses any credential or request outside the public discovery boundary.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsoluteUri != "https://printing.example.invalid/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization")
|
||||
if(request.Method != HttpMethod.Get
|
||||
|| !string.Equals(request.RequestUri?.AbsoluteUri, "https://printing.example.invalid/qqq/v1/version", StringComparison.Ordinal)
|
||||
|| request.Headers.Contains("Cookie")
|
||||
|| request.Headers.Contains("Authorization")
|
||||
|| request.Headers.Contains("X-QQQ-Device-Id") || request.Headers.Contains("X-QQQ-Device-Token"))
|
||||
{
|
||||
throw new InvalidOperationException("Native discovery must precede authentication and device proof.");
|
||||
@@ -84,7 +101,7 @@ internal static class NativeAuthDiscoveryChecks
|
||||
{
|
||||
apiVersion,
|
||||
features,
|
||||
clientChannels = new[] { "api" },
|
||||
clientChannels = s_channels,
|
||||
extensions = new[] { new { name = "printing", version = printingVersion, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Qualify real provider sessions only in the selected experimental environment.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const string CoreSource = "ecf0f550a0357cec6387c2b54efca128a405a629";
|
||||
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
|
||||
private const string InputPath = "/work/input.json";
|
||||
private const string ResultPath = "/work/result.json";
|
||||
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
|
||||
private static readonly List<string> Checks = [];
|
||||
private static string _stage = "input";
|
||||
private static bool? _authorizationRequestMatches;
|
||||
|
||||
|
||||
|
||||
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
|
||||
|
||||
|
||||
|
||||
private const string InputPath = "/work/input.json";
|
||||
|
||||
|
||||
|
||||
private const string ResultPath = "/work/result.json";
|
||||
|
||||
|
||||
|
||||
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
|
||||
|
||||
|
||||
|
||||
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
|
||||
|
||||
|
||||
|
||||
private static readonly List<string> s_checks = [];
|
||||
|
||||
|
||||
|
||||
private static string s_stage = "input";
|
||||
|
||||
|
||||
|
||||
private static bool? s_authorizationRequestMatches;
|
||||
|
||||
|
||||
|
||||
/// <summary>Validate synthetic input and record bounded evidence without credential output.</summary>
|
||||
public static async Task<int> Main(string[] arguments)
|
||||
{
|
||||
if(arguments is ["--check-discovery"])
|
||||
@@ -32,21 +61,28 @@ internal static class Program
|
||||
try
|
||||
{
|
||||
if(arguments.Length != 1 || arguments[0] is not ("assert-untrusted" or "test"))
|
||||
{ throw new InvalidOperationException("An explicit fixture action is required."); }
|
||||
{
|
||||
throw new InvalidOperationException("An explicit fixture action is required.");
|
||||
}
|
||||
if(new FileInfo(InputPath).Length is <= 0 or > 65536)
|
||||
{ throw new InvalidOperationException("Fixture input size is invalid."); }
|
||||
{
|
||||
throw new InvalidOperationException("Fixture input size is invalid.");
|
||||
}
|
||||
input = JsonNode.Parse(await File.ReadAllTextAsync(InputPath))!.AsObject();
|
||||
ValidateInput(input);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
if(arguments[0] == "assert-untrusted")
|
||||
if(string.Equals(arguments[0], "assert-untrusted", StringComparison.Ordinal))
|
||||
{
|
||||
_stage = "untrusted-tls";
|
||||
s_stage = "untrusted-tls";
|
||||
await AssertUntrustedAsync(new Uri(ProviderSetup.Text(input, "providerOrigin")), deadline.Token);
|
||||
await File.WriteAllTextAsync(TlsMarker, "passed", deadline.Token);
|
||||
return 0;
|
||||
}
|
||||
if(!File.Exists(TlsMarker)) { throw new InvalidOperationException("The ordinary TLS negative control did not run."); }
|
||||
Checks.Add("untrusted-tls-refused");
|
||||
if(!File.Exists(TlsMarker))
|
||||
{
|
||||
throw new InvalidOperationException("The ordinary TLS negative control did not run.");
|
||||
}
|
||||
s_checks.Add("untrusted-tls-refused");
|
||||
await QualifyAsync(input, deadline.Token);
|
||||
await WriteResultAsync(input, "PASS", null, null);
|
||||
Console.WriteLine("Experimental native Authentik qualification passed; development-only unpublished core.");
|
||||
@@ -57,158 +93,221 @@ internal static class Program
|
||||
// Exception messages, response bodies, callback URLs and credentials are deliberately withheld.
|
||||
await WriteResultAsync(input, "FAILED", exception.GetType().Name,
|
||||
exception is QqqSessionException sessionException ? sessionException.Failure.ToString() : null);
|
||||
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + _stage + " (" + exception.GetType().Name + ").");
|
||||
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + s_stage + " (" + exception.GetType().Name + ").");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuse fixture identities, origins, or source tuples outside the pinned contract.</summary>
|
||||
private static void ValidateInput(JsonObject input)
|
||||
{
|
||||
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant)
|
||||
|| ProviderSetup.Text(input, "coreSource") != CoreSource || ProviderSetup.Text(input, "clientSource") != ClientSource
|
||||
|| ProviderSetup.Text(input, "providerOrigin") != "https://auth.printing.internal"
|
||||
|| ProviderSetup.Text(input, "qqqOrigin") != "https://qqq.printing.internal"
|
||||
|| ProviderSetup.Text(input, "callback") != "https://callback.printing.internal/oauth/callback"
|
||||
|| ProviderSetup.Text(input, "issuer") != "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/"
|
||||
|| ProviderSetup.Text(input, "clientId") != ProviderSetup.Text(input, "project"))
|
||||
{ throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract."); }
|
||||
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "coreSource"), CoreSource, StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "clientSource"), ClientSource, StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "providerOrigin"), "https://auth.printing.internal", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "qqqOrigin"), "https://qqq.printing.internal", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "callback"), "https://callback.printing.internal/oauth/callback", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "issuer"), "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "clientId"), ProviderSetup.Text(input, "project"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create the production session client with redirects, cookies, and proxies controlled.</summary>
|
||||
private static QqqSessionClient Session(Uri origin) => new(origin,
|
||||
new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false });
|
||||
|
||||
|
||||
|
||||
/// <summary>Require TLS to reject the private CA before scoped trust is installed.</summary>
|
||||
private static async Task AssertUntrustedAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var transport = new FixtureHttp(origin);
|
||||
try { await transport.SendAsync("GET", "/-/health/live/", null, token); }
|
||||
try
|
||||
{
|
||||
await transport.SendAsync("GET", "/-/health/live/", null, token);
|
||||
}
|
||||
catch(HttpRequestException exception)
|
||||
{
|
||||
for(Exception? cause = exception; cause is not null; cause = cause.InnerException)
|
||||
{
|
||||
if(cause is AuthenticationException) { return; }
|
||||
if(cause is AuthenticationException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.");
|
||||
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.", exception);
|
||||
}
|
||||
throw new InvalidOperationException("The test unexpectedly trusted its private CA before scoped installation.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercise real discovery, authorization, origin enforcement, logout, renewal, and revocation.</summary>
|
||||
private static async Task QualifyAsync(JsonObject input, CancellationToken token)
|
||||
{
|
||||
var origin = new Uri(ProviderSetup.Text(input, "qqqOrigin"));
|
||||
var providerOrigin = new Uri(ProviderSetup.Text(input, "providerOrigin"));
|
||||
var callback = new Uri(ProviderSetup.Text(input, "callback"));
|
||||
_stage = "readiness";
|
||||
s_stage = "readiness";
|
||||
await WaitForQqqAsync(origin, token);
|
||||
using var provider = new ProviderSetup(input);
|
||||
_stage = "provider-readiness";
|
||||
s_stage = "provider-readiness";
|
||||
await provider.WaitReadyAsync(token);
|
||||
_stage = "provider-configuration";
|
||||
s_stage = "provider-configuration";
|
||||
await provider.ConfigureAsync(token);
|
||||
using var receiver = new CallbackReceiver(callback);
|
||||
using var first = Session(origin);
|
||||
_stage = "typed-native-discovery";
|
||||
using QqqSessionClient first = Session(origin);
|
||||
s_stage = "typed-native-discovery";
|
||||
await NativeAuthDiscovery.DiscoverAsync(first, token);
|
||||
QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token);
|
||||
if(metadata.Values?.ClientId != ProviderSetup.Text(input, "clientId"))
|
||||
{ throw new InvalidOperationException("Native metadata did not retain the configured client identifier."); }
|
||||
Checks.Add("typed-native-grant");
|
||||
|
||||
_stage = "real-provider-login";
|
||||
if(!string.Equals(metadata.Values?.ClientId, ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Native metadata did not retain the configured client identifier.");
|
||||
}
|
||||
s_checks.Add("typed-native-grant");
|
||||
s_stage = "real-provider-login";
|
||||
await LoginAsync(first, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
_stage = "authorized-native-identity";
|
||||
s_stage = "authorized-native-identity";
|
||||
QqqSessionBinding identity = await first.GetSessionIdentityAsync(token);
|
||||
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || identity.TenantId != "printing-native-dev" || identity.DeviceId is not null)
|
||||
{ throw new InvalidOperationException("The native subject did not receive the intended server-derived identity."); }
|
||||
Checks.Add("real-provider-login");
|
||||
_stage = "wrong-origin-cookie";
|
||||
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || !string.Equals(identity.TenantId, "printing-native-dev", StringComparison.Ordinal) || identity.DeviceId is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The native subject did not receive the intended server-derived identity.");
|
||||
}
|
||||
s_checks.Add("real-provider-login");
|
||||
s_stage = "wrong-origin-cookie";
|
||||
await AssertWrongOriginAsync(origin, first.ExportCredential(), token);
|
||||
await first.GetSessionIdentityAsync(token);
|
||||
Checks.Add("wrong-origin-cookie-denied");
|
||||
|
||||
_stage = "permission-denied-user";
|
||||
using(var denied = Session(origin))
|
||||
s_checks.Add("wrong-origin-cookie-denied");
|
||||
s_stage = "permission-denied-user";
|
||||
using(QqqSessionClient denied = Session(origin))
|
||||
{
|
||||
await LoginAsync(denied, receiver, input, providerOrigin, callback, authorized: false, token);
|
||||
await ExpectFailureAsync(() => denied.GetSessionIdentityAsync(token), QqqSessionException.FailureKind.PermissionDenied);
|
||||
await denied.SignOutAsync(token);
|
||||
}
|
||||
Checks.Add("permission-denied-user");
|
||||
s_checks.Add("permission-denied-user");
|
||||
await AssertLogoutReplayAsync(first, origin, token);
|
||||
await QualifyRenewalAsync(input, provider, receiver, origin, providerOrigin, callback, token);
|
||||
}
|
||||
|
||||
_stage = "logout-replay";
|
||||
|
||||
|
||||
/// <summary>Verify that signing out invalidates the exact previously exported native cookie.</summary>
|
||||
private static async Task AssertLogoutReplayAsync(QqqSessionClient first, Uri origin, CancellationToken token)
|
||||
{
|
||||
s_stage = "logout-replay";
|
||||
QqqSessionCredential retired = first.ExportCredential();
|
||||
await first.SignOutAsync(token);
|
||||
using(var replay = Session(origin))
|
||||
{ await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired); }
|
||||
Checks.Add("logout-replay-denied");
|
||||
using(QqqSessionClient replay = Session(origin))
|
||||
{
|
||||
await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired);
|
||||
}
|
||||
s_checks.Add("logout-replay-denied");
|
||||
}
|
||||
|
||||
_stage = "fresh-login-for-renewal";
|
||||
using var renewable = Session(origin);
|
||||
|
||||
|
||||
/// <summary>Cross real provider expiry and revocation while preserving the authenticated subject.</summary>
|
||||
private static async Task QualifyRenewalAsync(JsonObject input, ProviderSetup provider, CallbackReceiver receiver,
|
||||
Uri origin, Uri providerOrigin, Uri callback, CancellationToken token)
|
||||
{
|
||||
s_stage = "fresh-login-for-renewal";
|
||||
using QqqSessionClient renewable = Session(origin);
|
||||
await LoginAsync(renewable, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
QqqSessionBinding before = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> previousGrants = await provider.AccessGrantIdsAsync(token);
|
||||
_stage = "server-refresh-after-expiry";
|
||||
s_stage = "server-refresh-after-expiry";
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await renewable.ResumeSessionAsync(token);
|
||||
QqqSessionBinding after = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> refreshedGrants = await provider.AccessGrantIdsAsync(token);
|
||||
if(before != after || !refreshedGrants.Except(previousGrants, StringComparer.Ordinal).Any())
|
||||
{ throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant."); }
|
||||
Checks.Add("server-refresh-after-expiry");
|
||||
|
||||
_stage = "provider-revocation-after-expiry";
|
||||
{
|
||||
throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant.");
|
||||
}
|
||||
s_checks.Add("server-refresh-after-expiry");
|
||||
s_stage = "provider-revocation-after-expiry";
|
||||
await provider.RevokeRefreshGrantsAsync(token);
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await ExpectFailureAsync(() => renewable.ResumeSessionAsync(token), QqqSessionException.FailureKind.AuthenticationRequired);
|
||||
Checks.Add("provider-revocation-denied");
|
||||
_stage = "complete";
|
||||
s_checks.Add("provider-revocation-denied");
|
||||
s_stage = "complete";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Use the real PKCE attempt and provider callback without synthesizing identity.</summary>
|
||||
private static async Task LoginAsync(QqqSessionClient client, CallbackReceiver receiver, JsonObject input,
|
||||
Uri providerOrigin, Uri callback, bool authorized, CancellationToken token)
|
||||
{
|
||||
string role = authorized ? "authorized" : "denied";
|
||||
_stage = role + "-begin-pkce";
|
||||
s_stage = role + "-begin-pkce";
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(callback, token);
|
||||
_stage = role + "-generated-authorization-request";
|
||||
Dictionary<string, string> parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
|
||||
s_stage = role + "-generated-authorization-request";
|
||||
var parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
|
||||
.Select(entry => entry.Split('=', 2)).ToDictionary(pair => Uri.UnescapeDataString(pair[0]), pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
|
||||
_authorizationRequestMatches = attempt.AuthorizationUri.AbsolutePath == "/application/o/authorize/"
|
||||
&& parameters.GetValueOrDefault("client_id") == ProviderSetup.Text(input, "clientId")
|
||||
&& parameters.GetValueOrDefault("redirect_uri") == callback.AbsoluteUri
|
||||
&& parameters.GetValueOrDefault("response_type") == "code"
|
||||
&& parameters.GetValueOrDefault("code_challenge_method") == "S256"
|
||||
s_authorizationRequestMatches = string.Equals(attempt.AuthorizationUri.AbsolutePath, "/application/o/authorize/", StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("client_id"), ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("redirect_uri"), callback.AbsoluteUri, StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("response_type"), "code", StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("code_challenge_method"), "S256", StringComparison.Ordinal)
|
||||
&& parameters.GetValueOrDefault("state")?.Length == 43;
|
||||
if(_authorizationRequestMatches != true)
|
||||
{ throw new InvalidOperationException("The generated authorization request differs from the exact provider registration."); }
|
||||
_stage = role + "-provider-flow";
|
||||
if(s_authorizationRequestMatches != true)
|
||||
{
|
||||
throw new InvalidOperationException("The generated authorization request differs from the exact provider registration.");
|
||||
}
|
||||
s_stage = role + "-provider-flow";
|
||||
await ProviderFlow.AuthenticateAsync(attempt.AuthorizationUri, providerOrigin, callback,
|
||||
ProviderSetup.Text(input, role + "Username"), ProviderSetup.Text(input, role + "Password"), token);
|
||||
_stage = role + "-callback";
|
||||
s_stage = role + "-callback";
|
||||
Uri actualCallback = await receiver.ReadAsync(token);
|
||||
_stage = role + "-exchange";
|
||||
s_stage = role + "-exchange";
|
||||
await client.CompleteBrowserLoginAsync(attempt, actualCallback, token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Require the precise native authentication or permission failure.</summary>
|
||||
private static async Task ExpectFailureAsync(Func<Task> operation, QqqSessionException.FailureKind expected)
|
||||
{
|
||||
try { await operation(); }
|
||||
try
|
||||
{
|
||||
await operation();
|
||||
}
|
||||
catch(QqqSessionException exception) when(exception.Failure == expected) { return; }
|
||||
throw new InvalidOperationException("The expected native authentication or permission denial did not occur.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify the authenticated native boundary rejects a foreign Origin.</summary>
|
||||
private static async Task AssertWrongOriginAsync(Uri origin, QqqSessionCredential credential, CancellationToken token)
|
||||
{
|
||||
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false })
|
||||
{ Timeout = TimeSpan.FromSeconds(15), MaxResponseContentBufferSize = 65536 };
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
MaxResponseContentBufferSize = 65536
|
||||
};
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(origin, "/qqq/v1/qbit/printing/session"));
|
||||
request.Headers.Add("X-QQQ-Client", QqqSessionClient.ClientChannel);
|
||||
request.Headers.Add("Cookie", "sessionUUID=" + credential.SessionToken);
|
||||
request.Headers.Add("Origin", "https://wrong-origin.invalid");
|
||||
using HttpResponseMessage response = await http.SendAsync(request, token);
|
||||
if(response.StatusCode != HttpStatusCode.Forbidden)
|
||||
{ throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request."); }
|
||||
{
|
||||
throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Bound startup readiness reads to the owned QQQ host.</summary>
|
||||
private static async Task WaitForQqqAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var http = new FixtureHttp(origin);
|
||||
@@ -217,25 +316,45 @@ internal static class Program
|
||||
try
|
||||
{
|
||||
FixtureHttp.Reply response = await http.SendAsync("GET", "/qqq/v1/version", null, token);
|
||||
if(response.Status == HttpStatusCode.OK && response.Json is not null) { return; }
|
||||
if(response.Status == HttpStatusCode.OK && response.Json is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(HttpRequestException)
|
||||
{
|
||||
// The owned host is still starting; the bounded attempt loop retries and then fails explicitly.
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned native host did not become ready.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Write only allowlisted classifications and results to the private report.</summary>
|
||||
private static Task WriteResultAsync(JsonObject? input, string status, string? failureType, string? sessionFailure)
|
||||
=> File.WriteAllTextAsync(ResultPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
status, scope = "DEVELOPMENT_ONLY_UNRELEASED", coreSource = CoreSource, clientSource = ClientSource,
|
||||
project = input?["project"]?.ToString(), observedUtc = DateTimeOffset.UtcNow, stage = _stage, failureType,
|
||||
sessionFailure, lastRequestKind = FixtureHttp.LastRequestKind, lastHttpStatus = FixtureHttp.LastStatus,
|
||||
status,
|
||||
scope = "DEVELOPMENT_ONLY_UNRELEASED",
|
||||
coreSource = CoreSource,
|
||||
clientSource = ClientSource,
|
||||
project = input?["project"]?.ToString(),
|
||||
observedUtc = DateTimeOffset.UtcNow,
|
||||
stage = s_stage,
|
||||
failureType,
|
||||
sessionFailure,
|
||||
lastRequestKind = FixtureHttp.LastRequestKind,
|
||||
lastHttpStatus = FixtureHttp.LastStatus,
|
||||
lastProviderComponent = ProviderFlow.LastComponent,
|
||||
authorizationRequestMatches = _authorizationRequestMatches,
|
||||
authorizationRequestMatches = s_authorizationRequestMatches,
|
||||
requestClassifications = FixtureHttp.Trace,
|
||||
providerLookupKind = ProviderSetup.LastLookupKind, providerLookupCount = ProviderSetup.LastLookupCount,
|
||||
providerLookupKind = ProviderSetup.LastLookupKind,
|
||||
providerLookupCount = ProviderSetup.LastLookupCount,
|
||||
providerReadinessCounts = ProviderSetup.ReadinessCounts,
|
||||
checks = Checks, installedUiAcceptance = false, providerRefreshTokensInClient = false,
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
checks = s_checks,
|
||||
installedUiAcceptance = false,
|
||||
providerRefreshTokensInClient = false,
|
||||
}, s_reportOptions));
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses Authentik's real synthetic-user login stages; never fabricates a session or code.</summary>
|
||||
internal static class ProviderFlow
|
||||
{
|
||||
internal static string? LastComponent { get; private set; }
|
||||
internal static string? LastComponent
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Follow bounded provider navigation while confining redirects to fixture origins.</summary>
|
||||
internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token)
|
||||
{
|
||||
using var browser = new FixtureHttp(origin, cookies: true);
|
||||
@@ -17,15 +28,23 @@ internal static class ProviderFlow
|
||||
Uri next = authorization;
|
||||
for(int hop = 0; hop < 24; hop++)
|
||||
{
|
||||
if(next.GetLeftPart(UriPartial.Authority) == callback.GetLeftPart(UriPartial.Authority))
|
||||
if(string.Equals(next.GetLeftPart(UriPartial.Authority), callback.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
if(next.AbsolutePath != callback.AbsolutePath) { throw new InvalidOperationException("Unexpected callback path."); }
|
||||
if(!string.Equals(next.AbsolutePath, callback.AbsolutePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected callback path.");
|
||||
}
|
||||
FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token);
|
||||
if(result.Status != HttpStatusCode.OK) { throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect."); }
|
||||
if(result.Status != HttpStatusCode.OK)
|
||||
{
|
||||
throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(next.GetLeftPart(UriPartial.Authority) != origin.GetLeftPart(UriPartial.Authority))
|
||||
{ throw new InvalidOperationException("A provider redirect left the fixture."); }
|
||||
if(!string.Equals(next.GetLeftPart(UriPartial.Authority), origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A provider redirect left the fixture.");
|
||||
}
|
||||
FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token);
|
||||
if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect)
|
||||
{
|
||||
@@ -33,61 +52,82 @@ internal static class ProviderFlow
|
||||
continue;
|
||||
}
|
||||
if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal))
|
||||
{ throw new InvalidOperationException("Unexpected provider login navigation."); }
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider login navigation.");
|
||||
}
|
||||
string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/');
|
||||
if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))
|
||||
{ throw new InvalidOperationException("Unexpected provider flow identifier."); }
|
||||
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
|
||||
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
|
||||
bool redirected = false;
|
||||
for(int stage = 0; stage < 10; stage++)
|
||||
{
|
||||
string component = ProviderSetup.Text(challenge, "component");
|
||||
LastComponent = component switch
|
||||
{
|
||||
"ak-stage-identification" => "identification",
|
||||
"ak-stage-password" => "password",
|
||||
"ak-stage-consent" => "consent",
|
||||
"xak-flow-redirect" => "redirect",
|
||||
"ak-stage-access-denied" => "access-denied",
|
||||
"ak-stage-flow-error" => "flow-error",
|
||||
_ => "unsupported",
|
||||
};
|
||||
if(component == "xak-flow-redirect")
|
||||
{
|
||||
next = new Uri(origin, ProviderSetup.Text(challenge, "to"));
|
||||
redirected = true;
|
||||
break;
|
||||
}
|
||||
JsonObject response = component switch
|
||||
{
|
||||
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
|
||||
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
|
||||
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
|
||||
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
|
||||
};
|
||||
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
|
||||
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
|
||||
{ throw new InvalidOperationException("The real provider rejected the synthetic login response."); }
|
||||
throw new InvalidOperationException("Unexpected provider flow identifier.");
|
||||
}
|
||||
if(!redirected) { throw new InvalidOperationException("Provider stage bound exceeded."); }
|
||||
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
|
||||
next = await CompleteStagesAsync(browser, executor, origin, username, password, token);
|
||||
}
|
||||
throw new InvalidOperationException("Provider redirect bound exceeded.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Answer at most ten real provider challenges and return its declared redirect.</summary>
|
||||
private static async Task<Uri> CompleteStagesAsync(FixtureHttp browser, Uri executor, Uri origin, string username, string password, CancellationToken token)
|
||||
{
|
||||
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
|
||||
for(int stage = 0; stage < 10; stage++)
|
||||
{
|
||||
string component = ProviderSetup.Text(challenge, "component");
|
||||
LastComponent = component switch
|
||||
{
|
||||
"ak-stage-identification" => "identification",
|
||||
"ak-stage-password" => "password",
|
||||
"ak-stage-consent" => "consent",
|
||||
"xak-flow-redirect" => "redirect",
|
||||
"ak-stage-access-denied" => "access-denied",
|
||||
"ak-stage-flow-error" => "flow-error",
|
||||
_ => "unsupported",
|
||||
};
|
||||
if(string.Equals(component, "xak-flow-redirect", StringComparison.Ordinal))
|
||||
{
|
||||
return new Uri(origin, ProviderSetup.Text(challenge, "to"));
|
||||
}
|
||||
JsonObject response = component switch
|
||||
{
|
||||
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
|
||||
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
|
||||
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
|
||||
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
|
||||
};
|
||||
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
|
||||
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
|
||||
{
|
||||
throw new InvalidOperationException("The real provider rejected the synthetic login response.");
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Provider stage bound exceeded.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Follow only same-executor POST/redirect/GET responses and require a JSON challenge.</summary>
|
||||
private static async Task<JsonObject> ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token)
|
||||
{
|
||||
// Authentik uses POST/redirect/GET when a real stage accepts its answer.
|
||||
// Follow only that same HTTPS executor; other navigation uses typed flow redirects.
|
||||
for(int redirect = 0; redirect < 8; redirect++)
|
||||
{
|
||||
if(response.Status == HttpStatusCode.OK) { return FixtureHttp.RequireJson(response, HttpStatusCode.OK); }
|
||||
if(response.Status == HttpStatusCode.OK)
|
||||
{
|
||||
return FixtureHttp.RequireJson(response, HttpStatusCode.OK);
|
||||
}
|
||||
if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null)
|
||||
{ throw new InvalidOperationException("Unexpected provider stage response."); }
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider stage response.");
|
||||
}
|
||||
var location = new Uri(executor, response.Location);
|
||||
if(location.GetLeftPart(UriPartial.Authority) != executor.GetLeftPart(UriPartial.Authority)
|
||||
|| location.AbsolutePath != executor.AbsolutePath)
|
||||
{ throw new InvalidOperationException("A stage redirect left its exact HTTPS executor."); }
|
||||
if(!string.Equals(location.GetLeftPart(UriPartial.Authority), executor.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)
|
||||
|| !string.Equals(location.AbsolutePath, executor.AbsolutePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A stage redirect left its exact HTTPS executor.");
|
||||
}
|
||||
response = await browser.SendAsync("GET", location, null, token);
|
||||
}
|
||||
throw new InvalidOperationException("Provider stage redirect bound exceeded.");
|
||||
|
||||
@@ -1,43 +1,93 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Configures only the fresh project's synthetic provider and subjects.</summary>
|
||||
internal sealed class ProviderSetup : IDisposable
|
||||
{
|
||||
internal static string? LastLookupKind { get; private set; }
|
||||
internal static int? LastLookupCount { get; private set; }
|
||||
internal static string? LastLookupKind
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static int? LastLookupCount
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static Dictionary<string, int> ReadinessCounts { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly JsonObject _input;
|
||||
|
||||
|
||||
|
||||
private readonly FixtureHttp _admin;
|
||||
|
||||
|
||||
|
||||
private string _provider = "";
|
||||
|
||||
|
||||
|
||||
private string _authorizedUser = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>Use the private bootstrap token only with the configured provider origin.</summary>
|
||||
internal ProviderSetup(JsonObject input)
|
||||
{
|
||||
_input = input;
|
||||
_admin = new FixtureHttp(new Uri(Text(input, "providerOrigin")), adminToken: Text(input, "adminToken"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read required synthetic input without disclosing its value in failures.</summary>
|
||||
internal static string Text(JsonObject document, string key) => document[key]?.ToString() ?? throw new InvalidOperationException("Required fixture field is absent.");
|
||||
|
||||
|
||||
|
||||
/// <summary>Require one provider metadata object and retain only its lookup classification.</summary>
|
||||
private async Task<JsonObject> ExactAsync(string path, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", path, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
LastLookupKind = LookupKind(path);
|
||||
LastLookupCount = records.Count;
|
||||
if(records.Count != 1) { throw new InvalidOperationException("Required provider object is absent or ambiguous."); }
|
||||
if(records.Count != 1)
|
||||
{
|
||||
throw new InvalidOperationException("Required provider object is absent or ambiguous.");
|
||||
}
|
||||
return records[0]!.AsObject();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create one synthetic object and require its successful JSON response.</summary>
|
||||
private async Task<JsonObject> CreateAsync(string path, JsonObject body, CancellationToken token)
|
||||
=> FixtureHttp.RequireJson(await _admin.SendAsync("POST", path, body, token), HttpStatusCode.Created);
|
||||
|
||||
|
||||
|
||||
/// <summary>Wait for complete default flows and scopes before configuring the provider.</summary>
|
||||
internal async Task WaitReadyAsync(CancellationToken token)
|
||||
{
|
||||
string[] paths = ["/api/v3/flows/instances/?slug=default-authentication-flow",
|
||||
@@ -58,20 +108,34 @@ internal sealed class ProviderSetup : IDisposable
|
||||
JsonArray? records = reply.Status == HttpStatusCode.OK ? reply.Json?["results"] as JsonArray : null;
|
||||
string kind = LookupKind(path);
|
||||
ReadinessCounts[kind] = records?.Count ?? -1;
|
||||
if(records?.Count > 1) { throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous."); }
|
||||
if(records?.Count > 1)
|
||||
{
|
||||
throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous.");
|
||||
}
|
||||
bool present = records is { Count: 1 };
|
||||
if(kind == "authentication-flow")
|
||||
{ present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 }; }
|
||||
if(string.Equals(kind, "authentication-flow", StringComparison.Ordinal))
|
||||
{
|
||||
present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 };
|
||||
}
|
||||
ready &= present;
|
||||
}
|
||||
if(ready) { return; }
|
||||
if(ready)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(HttpRequestException)
|
||||
{
|
||||
// The owned provider is still starting; the bounded attempt loop retries and then fails explicitly.
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned provider bootstrap did not become ready.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Classify prerequisite lookups using fixed diagnostic names.</summary>
|
||||
private static string LookupKind(string path) => path switch
|
||||
{
|
||||
"/api/v3/flows/instances/?slug=default-authentication-flow" => "authentication-flow",
|
||||
@@ -84,6 +148,9 @@ internal sealed class ProviderSetup : IDisposable
|
||||
_ => "other",
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>Register the synthetic OAuth provider and authorized and denied users.</summary>
|
||||
internal async Task ConfigureAsync(CancellationToken token)
|
||||
{
|
||||
JsonObject authentication = await ExactAsync("/api/v3/flows/instances/?slug=default-authentication-flow", token);
|
||||
@@ -125,60 +192,97 @@ internal sealed class ProviderSetup : IDisposable
|
||||
["include_claims_in_id_token"] = true,
|
||||
}, token);
|
||||
_provider = Text(provider, "pk");
|
||||
if(Text(provider, "client_id") != Text(_input, "clientId")
|
||||
|| Text(provider, "authentication_flow") != Text(authentication, "pk"))
|
||||
{ throw new InvalidOperationException("The real provider did not retain the configured client identifier."); }
|
||||
if(!string.Equals(Text(provider, "client_id"), Text(_input, "clientId"), StringComparison.Ordinal) || !string.Equals(Text(provider, "authentication_flow"), Text(authentication, "pk"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The real provider did not retain the configured client identifier.");
|
||||
}
|
||||
JsonObject application = await CreateAsync("/api/v3/core/applications/", new JsonObject
|
||||
{
|
||||
["name"] = Text(_input, "project"), ["slug"] = Text(_input, "project"),
|
||||
["provider"] = provider["pk"]!.DeepClone(), ["policy_engine_mode"] = "all",
|
||||
["name"] = Text(_input, "project"),
|
||||
["slug"] = Text(_input, "project"),
|
||||
["provider"] = provider["pk"]!.DeepClone(),
|
||||
["policy_engine_mode"] = "all",
|
||||
}, token);
|
||||
if(Text(application, "provider") != _provider)
|
||||
{ throw new InvalidOperationException("The real provider application binding differs from the configured provider."); }
|
||||
if(!string.Equals(Text(application, "provider"), _provider, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The real provider application binding differs from the configured provider.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create a synthetic user with configured groups and its private password.</summary>
|
||||
private async Task<JsonObject> CreateUserAsync(string usernameKey, string passwordKey, JsonArray groups, CancellationToken token)
|
||||
{
|
||||
JsonObject user = await CreateAsync("/api/v3/core/users/", new JsonObject
|
||||
{
|
||||
["username"] = Text(_input, usernameKey), ["name"] = "Synthetic native qualification subject",
|
||||
["email"] = Text(_input, usernameKey) + "@example.invalid", ["is_active"] = true,
|
||||
["type"] = "internal", ["groups"] = groups,
|
||||
["username"] = Text(_input, usernameKey),
|
||||
["name"] = "Synthetic native qualification subject",
|
||||
["email"] = Text(_input, usernameKey) + "@example.invalid",
|
||||
["is_active"] = true,
|
||||
["type"] = "internal",
|
||||
["groups"] = groups,
|
||||
}, token);
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("POST", "/api/v3/core/users/" + Text(user, "pk") + "/set_password/",
|
||||
new JsonObject { ["password"] = Text(_input, passwordKey) }, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic account password configuration failed."); }
|
||||
if(response.Status != HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new InvalidOperationException("Synthetic account password configuration failed.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read bounded provider token metadata scoped to this synthetic application.</summary>
|
||||
private async Task<JsonArray> GrantsAsync(string kind, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", "/api/v3/oauth2/" + kind + "/?provider=" + _provider + "&user=" + _authorizedUser, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
if(records.Count is 0 or > 20) { throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large."); }
|
||||
if(records.Count is 0 or > 20)
|
||||
{
|
||||
throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large.");
|
||||
}
|
||||
foreach(JsonNode? node in records)
|
||||
{
|
||||
JsonObject record = node!.AsObject();
|
||||
string provider = record["provider"] is JsonObject nested ? Text(nested, "pk") : Text(record, "provider");
|
||||
if(provider != _provider || Text(record["user"]!.AsObject(), "pk") != _authorizedUser)
|
||||
{ throw new InvalidOperationException("The provider grant escaped the synthetic subject scope."); }
|
||||
if(!string.Equals(provider, _provider, StringComparison.Ordinal) || !string.Equals(Text(record["user"]!.AsObject(), "pk"), _authorizedUser, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The provider grant escaped the synthetic subject scope.");
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Snapshot actual access-grant identities for the renewal assertion.</summary>
|
||||
internal async Task<HashSet<string>> AccessGrantIdsAsync(CancellationToken token)
|
||||
=> (await GrantsAsync("access_tokens", token)).Select(node => Text(node!.AsObject(), "pk")).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>Revoke only refresh grants belonging to the owned synthetic provider.</summary>
|
||||
internal async Task RevokeRefreshGrantsAsync(CancellationToken token)
|
||||
{
|
||||
foreach(JsonNode? node in await GrantsAsync("refresh_tokens", token))
|
||||
{
|
||||
string id = Text(node!.AsObject(), "pk");
|
||||
if(!Guid.TryParse(id, out _) && !long.TryParse(id, out _)) { throw new InvalidOperationException("Unexpected provider grant identifier."); }
|
||||
if(!Guid.TryParse(id, out _) && !long.TryParse(id, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider grant identifier.");
|
||||
}
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("DELETE", "/api/v3/oauth2/refresh_tokens/" + id + "/", null, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic provider revocation failed."); }
|
||||
if(response.Status != HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new InvalidOperationException("Synthetic provider revocation failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Dispose the private provider administration transport.</summary>
|
||||
public void Dispose() => _admin.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
@@ -54,22 +56,28 @@ import io.javalin.http.ForbiddenResponse;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Opt-in container fixture for the pinned upstream native-session candidate.
|
||||
* Standard OAuth2 code exchange, session storage and encrypted renewal are used
|
||||
* unchanged. No installed pairing, password authentication double or printer.
|
||||
*/
|
||||
|
||||
/***************************************************************************
|
||||
** Opt-in container fixture for the pinned upstream native-session candidate.
|
||||
** Standard OAuth2 code exchange, session storage and encrypted renewal are used
|
||||
** unchanged. No installed pairing, password authentication double or printer.
|
||||
***************************************************************************/
|
||||
public final class NativeAuthHost extends AbstractQQQApplication
|
||||
{
|
||||
private static final String TENANT = "printing-native-dev";
|
||||
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
|
||||
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
|
||||
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
|
||||
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
|
||||
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
|
||||
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
|
||||
private final PrintingService service = new PrintingService(source, TENANT);
|
||||
private static final String TENANT = "printing-native-dev";
|
||||
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
|
||||
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
|
||||
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
|
||||
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
|
||||
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
|
||||
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
|
||||
private final PrintingService service = new PrintingService(source, TENANT);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Initialize only the private fixture database and native session tables.
|
||||
***************************************************************************/
|
||||
private NativeAuthHost() throws Exception
|
||||
{
|
||||
if(port < 1024 || port > 65535)
|
||||
@@ -85,6 +93,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Launch the experimental provider host inside its owned container network.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
NativeAuthHost host = new NativeAuthHost();
|
||||
@@ -92,6 +105,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
.withServerCustomizer(host::configure));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Define the private fixture backend, native OAuth2 provider and printing QBit.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
@@ -105,8 +123,8 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance));
|
||||
instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance));
|
||||
|
||||
String issuer = required("NATIVE_AUTH_ISSUER");
|
||||
URI issuerUri = URI.create(issuer);
|
||||
String issuer = required("NATIVE_AUTH_ISSUER");
|
||||
URI issuerUri = URI.create(issuer);
|
||||
if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null
|
||||
|| issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/"))
|
||||
{
|
||||
@@ -142,6 +160,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register printing routes behind the fixture TLS proxy.
|
||||
***************************************************************************/
|
||||
private void configure(QApplicationJavalinServer server)
|
||||
{
|
||||
// Only the owned TLS proxy is published; this HTTP listener remains inside
|
||||
@@ -156,6 +179,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Require the configured HTTPS proxy and same-origin cookie mutations.
|
||||
***************************************************************************/
|
||||
private void guardOrigin(Context context)
|
||||
{
|
||||
if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host"))
|
||||
@@ -181,9 +209,16 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
/** Only maps claims after the unchanged standard OAuth2 module authenticates. */
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only maps claims after the unchanged standard OAuth2 module authenticates.
|
||||
***************************************************************************/
|
||||
public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Grant the printing admin permission only for the fixture group claim.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
@@ -201,6 +236,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate the exact root HTTPS origin used by the private TLS fixture.
|
||||
***************************************************************************/
|
||||
private static URI httpsOrigin(String value)
|
||||
{
|
||||
URI parsed = URI.create(value);
|
||||
@@ -212,6 +252,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a required synthetic fixture setting without logging its value.
|
||||
***************************************************************************/
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Java compiler safety
|
||||
|
||||
The repository compiler runs Error Prone 2.51.0 and NullAway 0.14.3 for
|
||||
`com.kingsrook.qbits.printing`, including test sources. NullAway findings are
|
||||
errors; compiler warnings fail through `-Werror` and Maven `failOnWarning`.
|
||||
This implements K22-CODE-JV-06 and the compiler portion of K22-CODE-02. It is
|
||||
not a claim that every unrelated coding-standard requirement is complete.
|
||||
|
||||
The configuration retains Java 21, compiler plugin 3.14.1, `proc:none`, the
|
||||
existing parent and the frozen QQQ dependency tuple. The explicit compiler
|
||||
plugin path loads the analyzers even with annotation processing disabled.
|
||||
JSpecify 1.0.0 is declared directly for nullable contracts; it was already in
|
||||
the runtime closure. NullAway's contract annotations are provided-only.
|
||||
Neither analyzer implementation is an application runtime dependency.
|
||||
|
||||
Run the usual quality gate:
|
||||
|
||||
```sh
|
||||
./mvnw -B -ntp -Pui-demo verify
|
||||
```
|
||||
|
||||
CI also runs the bounded compiler-configuration guard:
|
||||
|
||||
```sh
|
||||
python3 .github/scripts/check-java-compiler.py
|
||||
```
|
||||
|
||||
The guard copies the actual POM compiler block into fresh, isolated modules
|
||||
under `target/compiler-guard`. Both main and test sources have a positive
|
||||
probe and negative probes for NullAway, Error Prone's `DeadException`, and a
|
||||
deprecated call rejected by warnings-as-errors. A random Maven failure does
|
||||
not satisfy a negative probe: the expected diagnostic must be present.
|
||||
Logs and a result manifest are retained in the normal CI test evidence.
|
||||
No artifact is installed or published. Optional `--maven`, `--settings`,
|
||||
`--local-repository` and `--offline` arguments support an isolated local cache.
|
||||
|
||||
Null annotations describe existing optional inputs and transitions; they do
|
||||
not convert absent data into required configuration. Existing lifecycle
|
||||
checks provide checked initializer contracts. HELLO-dependent work receives
|
||||
the validated payload explicitly. Scheduled retention captures its service
|
||||
under the existing missing-configuration error contract. A missing realtime
|
||||
QQQ instance now fails descriptively at revalidation, without moving host
|
||||
registration timing or caching user authority.
|
||||
|
||||
Narrow member suppressions retain required framework override return types,
|
||||
exact socket identity fencing and the framework's ordered presentation enum.
|
||||
Two private accessors document the storage/page lifecycle invariants that
|
||||
cannot be inferred from separate nullable fields. They preserve the existing
|
||||
use-after-finish or misconfigured-storage failure rather than inventing valid
|
||||
null operations. Suppressions are not package-wide, and tests remain analyzed.
|
||||
|
||||
The migration separates redundant return-expression parentheses from null
|
||||
contracts and compiler configuration for review. Public JVM descriptors and
|
||||
the resolved runtime JAR set are verified against the unchanged baseline.
|
||||
The ordinary test suite intentionally skips the separately opt-in 10,000-job
|
||||
qualification unless `printing.qualification.load=true` is supplied.
|
||||
@@ -19,6 +19,20 @@
|
||||
- [ ] Publish the reviewed additive contract and coordinate client/runtime adoption
|
||||
before owner-operated end-to-end physical acceptance.
|
||||
|
||||
## Script quality gates
|
||||
|
||||
- [x] Pin Ruff and strict Pyright with a locked restore; reject Python outside `.github/scripts/` and zero-file Pyright runs; negative tool probes pass locally.
|
||||
- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them.
|
||||
|
||||
## Integrated compiler, standards and fixture gates
|
||||
|
||||
- [x] Layer the reviewed Error Prone/NullAway compiler gate, null contracts and
|
||||
style corrections over the merged 2.3.0 source without relaxing any security check.
|
||||
- [ ] Qualify this integrated runtime again (compile gate, full test suite with
|
||||
adversarial regressions, eight compiler probes, development fixture proofs); the
|
||||
earlier 133-test/204-jar results describe the old baseline only.
|
||||
- [ ] Observe the `development-clients` and `development-servers` jobs pass on hosted CI.
|
||||
|
||||
## Current local development qualification
|
||||
|
||||
- [x] Reuse production discovery and the shared major-2 gate in the optional
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -25,6 +25,9 @@
|
||||
<dependency><groupId>org.junit</groupId><artifactId>junit-bom</artifactId><version>6.0.1</version><type>pom</type><scope>import</scope></dependency>
|
||||
</dependencies></dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.uber.nullaway</groupId><artifactId>nullaway-annotations</artifactId><version>0.14.3</version><scope>provided</scope></dependency>
|
||||
<!-- Direct nullable contracts reuse the already-resolved runtime annotation version. -->
|
||||
<dependency><groupId>org.jspecify</groupId><artifactId>jspecify</artifactId><version>1.0.0</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId><version>${qqq.version}</version></dependency>
|
||||
@@ -49,10 +52,57 @@
|
||||
<plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.3.1</version>
|
||||
<dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>10.12.6</version></dependency></dependencies>
|
||||
<configuration><configLocation>checkstyle/config.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><consoleOutput>true</consoleOutput><violationSeverity>warning</violationSeverity><failOnViolation>true</failOnViolation></configuration>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<!-- Development fixtures use distinct runtime tuples, but share the source style gate. -->
|
||||
<sourceDirectories>
|
||||
<sourceDirectory>${project.build.sourceDirectory}</sourceDirectory>
|
||||
<sourceDirectory>${project.basedir}/dev/java</sourceDirectory>
|
||||
<sourceDirectory>${project.basedir}/dev/native-auth/java</sourceDirectory>
|
||||
</sourceDirectories>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
</configuration>
|
||||
<executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions>
|
||||
</plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.1</version>
|
||||
<configuration>
|
||||
<release>21</release>
|
||||
<proc>none</proc>
|
||||
<fork>true</fork>
|
||||
<executable>${java.home}/bin/javac</executable>
|
||||
<showWarnings>true</showWarnings>
|
||||
<failOnWarning>true</failOnWarning>
|
||||
<compilerArgs>
|
||||
<arg>-Werror</arg>
|
||||
<arg>-Xlint:deprecation</arg>
|
||||
<arg>-Xlint:unchecked</arg>
|
||||
<arg>-XDcompilePolicy=simple</arg>
|
||||
<arg>--should-stop=ifError=FLOW</arg>
|
||||
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
|
||||
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.kingsrook.qbits.printing</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
|
||||
</compilerArgs>
|
||||
<annotationProcessorPaths>
|
||||
<path><groupId>com.google.errorprone</groupId><artifactId>error_prone_core</artifactId><version>2.51.0</version></path>
|
||||
<path><groupId>com.uber.nullaway</groupId><artifactId>nullaway</artifactId><version>0.14.3</version></path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version><configuration><archive><manifestEntries><Implementation-Version>${project.version}</Implementation-Version></manifestEntries></archive></configuration></plugin>
|
||||
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>com.kingsrook.qbits.printing.PrintingDemo</mainClass></configuration></plugin>
|
||||
|
||||
@@ -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"
|
||||
@@ -27,6 +27,7 @@ import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -37,7 +38,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException
|
||||
{
|
||||
return (DriverManager.getConnection(url, user, password));
|
||||
return DriverManager.getConnection(url, user, password);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +46,15 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Connection getConnection(String username, String secret) throws SQLException
|
||||
{
|
||||
return (DriverManager.getConnection(url, username, secret));
|
||||
return DriverManager.getConnection(url, username, secret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public PrintWriter getLogWriter()
|
||||
public @Nullable PrintWriter getLogWriter()
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +78,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public int getLoginTimeout()
|
||||
{
|
||||
return (DriverManager.getLoginTimeout());
|
||||
return DriverManager.getLoginTimeout();
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +86,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Logger getParentLogger()
|
||||
{
|
||||
return (Logger.getLogger("com.kingsrook.qbits.printing"));
|
||||
return Logger.getLogger("com.kingsrook.qbits.printing");
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +96,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
{
|
||||
if(type.isInstance(this))
|
||||
{
|
||||
return (type.cast(this));
|
||||
return type.cast(this);
|
||||
}
|
||||
throw new SQLException("Not a wrapper");
|
||||
}
|
||||
@@ -105,7 +106,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public boolean isWrapperFor(Class<?> type)
|
||||
{
|
||||
return (type.isInstance(this));
|
||||
return type.isInstance(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +114,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("DriverDataSource[credentials redacted]");
|
||||
return "DriverDataSource[credentials redacted]";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
try
|
||||
{
|
||||
JSONObject job = serviceForEffect().submit(payload);
|
||||
return (EffectResult.sent(job.getString("jobId")));
|
||||
return EffectResult.sent(job.getString("jobId"));
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
return (EffectResult.failed(exception.getMessage()));
|
||||
return EffectResult.failed(exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
public ReconcileResult reconcile(EffectRequest request) throws Exception
|
||||
{
|
||||
JSONObject job = serviceForEffect().findByIdempotencyKey(request.idempotencyKey());
|
||||
return (job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId")));
|
||||
return job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId"));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,13 +79,13 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
{
|
||||
if(!(QContext.getQSession() instanceof QSystemUserSession))
|
||||
{
|
||||
return (PrintingProcesses.currentService());
|
||||
return PrintingProcesses.currentService();
|
||||
}
|
||||
for(QBitMetaData metadata : QContext.getQInstance().getQBits().values())
|
||||
{
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
|
||||
{
|
||||
return (config.getService());
|
||||
return config.getService();
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured for this effect dispatcher");
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -47,7 +48,7 @@ public class PrintingAdminPermission extends UseOtherPermissionNameCustomPermiss
|
||||
** Enforce authorization even for direct backend-step invocation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission("printing_admin"))
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -174,7 +175,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** SQL aggregates stay bounded by active capacity and the rolling hour index.
|
||||
***************************************************************************/
|
||||
private static void checkScope(Connection connection, String tenant, String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
|
||||
private static void checkScope(Connection connection, String tenant, @Nullable String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
|
||||
{
|
||||
String predicate = "tenant_id=?" + (device == null ? "" : " AND device_id=?");
|
||||
String outstanding = "SELECT content_length FROM printing_job WHERE " + predicate + " AND status IN " + ACTIVE
|
||||
@@ -205,11 +206,11 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Failed upload reservations stay charged until exact confirmed removal.
|
||||
***************************************************************************/
|
||||
private static long orphanBytes(Connection connection, String tenant, String device) throws SQLException
|
||||
private static long orphanBytes(Connection connection, String tenant, @Nullable String device) throws SQLException
|
||||
{
|
||||
String sql = "SELECT COALESCE(SUM(COALESCE(content_length,20971520)),0) FROM printing_document_orphan o WHERE tenant_id=?"
|
||||
+ (device == null ? "" : " AND device_id=?") + " AND NOT EXISTS(SELECT 1 FROM printing_job j WHERE j.id=o.id)";
|
||||
return (device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device));
|
||||
return device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device);
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +218,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Release only after deletion; an inconsistent counter fails closed.
|
||||
***************************************************************************/
|
||||
static void release(Connection connection, String tenant, String device, long bytes) throws SQLException
|
||||
static void release(Connection connection, String tenant, @Nullable String device, long bytes) throws SQLException
|
||||
{
|
||||
int expected = device == null ? 1 : 2;
|
||||
String predicate = device == null ? "device_id=''" : "device_id IN ('',?)";
|
||||
@@ -255,7 +256,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Read one scalar; never materialize payloads or an unbounded row list.
|
||||
***************************************************************************/
|
||||
private static long number(Connection connection, String sql, Object... values) throws SQLException
|
||||
private static long number(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(var statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -269,7 +270,7 @@ final class PrintingAdmission
|
||||
{
|
||||
throw new SQLException("Missing printing resource accounting row");
|
||||
}
|
||||
return (rows.getLong(1));
|
||||
return rows.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +280,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** All identifiers are fixed SQL; only values come from callers.
|
||||
***************************************************************************/
|
||||
private static int execute(Connection connection, String sql, Object... values) throws SQLException
|
||||
private static int execute(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(var statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -287,7 +288,7 @@ final class PrintingAdmission
|
||||
{
|
||||
statement.setObject(index + 1, values[index]);
|
||||
}
|
||||
return (statement.executeUpdate());
|
||||
return statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public record PrintingAdmissionLimits(int deviceOutstandingJobs, int tenantOutst
|
||||
public static PrintingAdmissionLimits defaults()
|
||||
{
|
||||
long gibibyte = 1024L * 1024 * 1024;
|
||||
return (new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
|
||||
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000));
|
||||
return new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
|
||||
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
try(Connection connection = source.getConnection())
|
||||
{
|
||||
return (json(effective(connection)));
|
||||
return json(effective(connection));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class PrintingAdmissionSettings
|
||||
throw new SQLException("The tenant admission settings row is unavailable");
|
||||
}
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
return (defaults);
|
||||
return defaults;
|
||||
}
|
||||
String stored = rows.getString(1);
|
||||
return (stored == null ? defaults : parse(new JSONObject(stored)));
|
||||
return stored == null ? defaults : parse(new JSONObject(stored));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,9 +123,9 @@ public class PrintingAdmissionSettings
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
|
||||
return new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
|
||||
number(value, "deviceOutstandingBytes"), number(value, "tenantOutstandingBytes"), number(value, "deviceRetainedBytes"), number(value, "tenantRetainedBytes"),
|
||||
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters")));
|
||||
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters"));
|
||||
}
|
||||
catch(ArithmeticException exception)
|
||||
{
|
||||
@@ -140,7 +140,7 @@ public class PrintingAdmissionSettings
|
||||
***************************************************************************/
|
||||
private static int integer(JSONObject value, String field)
|
||||
{
|
||||
return (Math.toIntExact(number(value, field)));
|
||||
return Math.toIntExact(number(value, field));
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
throw new IllegalArgumentException(field + " is outside the supported positive range");
|
||||
}
|
||||
return (parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,10 +169,10 @@ public class PrintingAdmissionSettings
|
||||
***************************************************************************/
|
||||
private static JSONObject json(PrintingAdmissionLimits value)
|
||||
{
|
||||
return (new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
|
||||
return new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
|
||||
.put("deviceOutstandingBytes", value.deviceOutstandingBytes()).put("tenantOutstandingBytes", value.tenantOutstandingBytes())
|
||||
.put("deviceRetainedBytes", value.deviceRetainedBytes()).put("tenantRetainedBytes", value.tenantRetainedBytes())
|
||||
.put("deviceJobsPerHour", value.deviceJobsPerHour()).put("tenantJobsPerHour", value.tenantJobsPerHour())
|
||||
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters()));
|
||||
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters());
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public final class PrintingAgentAuthorization
|
||||
String principal = PrintingControlAuthorization.principal();
|
||||
PrintingControlAuthorization.record(PrintingTables.DEVICE, id);
|
||||
PrintingProcesses.currentService().authorizeAgent(id, token, principal, bind);
|
||||
return (id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class PrintingAgentAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Printer is not accessible");
|
||||
}
|
||||
return (PrintingControlAuthorization.record(PrintingTables.PRINTER, rows.getFirst().getValueString("id")));
|
||||
return PrintingControlAuthorization.record(PrintingTables.PRINTER, rows.getFirst().getValueString("id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ public final class PrintingAgentAuthorization
|
||||
// Even an empty inventory must have native table READ access.
|
||||
PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withLimit(1)));
|
||||
return (authorized);
|
||||
return authorized;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,9 @@ public final class PrintingAgentAuthorization
|
||||
JSONObject result = PrintingProcesses.currentService().claimForJob(device, id);
|
||||
if(!result.isNull("job"))
|
||||
{
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
}
|
||||
@@ -76,28 +76,28 @@ final class PrintingApiSchemas
|
||||
add(schemas, "ActionRequest", fields("requestId", id(), "issuedAt", time(), "action", actions, "jobId", id()), "requestId", "issuedAt", "action");
|
||||
add(schemas, "Action", fields("commandId", id(), "requestId", id(), "issuedAt", time(), "deviceId", id(), "action", actions, "status", enumeration("PENDING", "SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "createdAt", time(), "expiresAt", time(), "jobId", id(), "resultCode", text(80), "cancelledJobCount", number(), "receipt", ref("ReceiptRequest")), "commandId", "requestId", "issuedAt", "deviceId", "action", "status", "createdAt", "expiresAt");
|
||||
add(schemas, "Actions", fields("commands", array(ref("Action")), "nextCursor", text(128).withNullable(true)), "commands", "nextCursor");
|
||||
return (schemas);
|
||||
return schemas;
|
||||
}
|
||||
|
||||
|
||||
/** Every schema has the extension prefix required by the native registry. */
|
||||
static String name(String shortName)
|
||||
{
|
||||
return ("Printing" + shortName + "V1");
|
||||
return "Printing" + shortName + "V1";
|
||||
}
|
||||
|
||||
|
||||
/** Refer to one typed reusable component. */
|
||||
static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name(name)));
|
||||
return new Schema().withRefToSchema(name(name));
|
||||
}
|
||||
|
||||
|
||||
/** Bounded Unicode text. */
|
||||
static Schema text(int limit)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withMaxLength(limit));
|
||||
return new Schema().withType(Type.STRING).withMaxLength(limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ final class PrintingApiSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new RequiredSchema(required).withType(Type.OBJECT).withProperties(properties));
|
||||
return new RequiredSchema(required).withType(Type.OBJECT).withProperties(properties);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ final class PrintingApiSchemas
|
||||
/** Published by the core Jackson schema serializer. */
|
||||
public List<String> getRequired()
|
||||
{
|
||||
return (required);
|
||||
return required;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ final class PrintingApiSchemas
|
||||
{
|
||||
fields.put((String) pairs[index], (Schema) pairs[index + 1]);
|
||||
}
|
||||
return (fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,41 +153,41 @@ final class PrintingApiSchemas
|
||||
/** Protocol identifier. */
|
||||
private static Schema id()
|
||||
{
|
||||
return (text(128));
|
||||
return text(128);
|
||||
}
|
||||
|
||||
|
||||
/** UTC wire timestamp. */
|
||||
private static Schema time()
|
||||
{
|
||||
return (text(40).withFormat("date-time").withDescription("RFC3339 UTC ending in uppercase Z."));
|
||||
return text(40).withFormat("date-time").withDescription("RFC3339 UTC ending in uppercase Z.");
|
||||
}
|
||||
|
||||
|
||||
/** Exact JSON integer, never a stringified numeric value. */
|
||||
private static Schema number()
|
||||
{
|
||||
return (new Schema().withType(Type.INTEGER));
|
||||
return new Schema().withType(Type.INTEGER);
|
||||
}
|
||||
|
||||
|
||||
/** Required booleans must never be inferred from missing fields. */
|
||||
private static Schema bool()
|
||||
{
|
||||
return (new Schema().withType(Type.BOOLEAN));
|
||||
return new Schema().withType(Type.BOOLEAN);
|
||||
}
|
||||
|
||||
|
||||
/** Closed action or status vocabulary. */
|
||||
private static Schema enumeration(String... values)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withEnumValues(List.of(values)));
|
||||
return new Schema().withType(Type.STRING).withEnumValues(List.of(values));
|
||||
}
|
||||
|
||||
|
||||
/** Homogeneous typed arrays. */
|
||||
private static Schema array(Schema items)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(items));
|
||||
return new Schema().withType(Type.ARRAY).withItems(items);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -47,16 +48,16 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** The fields from process values, with server-side defaults.
|
||||
***************************************************************************/
|
||||
public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle)
|
||||
public static PrintingChrome from(RunBackendStepInput input, @Nullable String fallbackTitle)
|
||||
{
|
||||
return (new PrintingChrome(
|
||||
return new PrintingChrome(
|
||||
value(input, "headerApp", defaultApp()),
|
||||
value(input, "headerTitle", fallbackTitle),
|
||||
value(input, "headerServer", ""),
|
||||
value(input, "headerUser", defaultUser()),
|
||||
value(input, "headerPrintedAt", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()),
|
||||
value(input, "footerLeft", "QQQ printing"),
|
||||
value(input, "footerNote", "")));
|
||||
value(input, "footerNote", ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +67,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
***************************************************************************/
|
||||
public static PrintingChrome defaults(String title)
|
||||
{
|
||||
return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", ""));
|
||||
return new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +75,9 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** The same chrome with a footer note (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
public PrintingChrome withFooterNote(String note)
|
||||
public PrintingChrome withFooterNote(@Nullable String note)
|
||||
{
|
||||
return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote));
|
||||
return new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +105,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
out.append(out.isEmpty() ? "" : " · ").append(part);
|
||||
}
|
||||
}
|
||||
return (out.toString());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +113,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** A string value, or the default when absent or blank (bounded length).
|
||||
***************************************************************************/
|
||||
private static String value(RunBackendStepInput input, String name, String fallback)
|
||||
private static String value(RunBackendStepInput input, String name, @Nullable String fallback)
|
||||
{
|
||||
String value = input == null ? null : input.getValueString(name);
|
||||
String chosen = value == null || value.isBlank() ? fallback : value;
|
||||
@@ -133,7 +134,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
}
|
||||
catch(RuntimeException exception)
|
||||
{
|
||||
return ("QQQ");
|
||||
return "QQQ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
Set<String> selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null;
|
||||
if(selected != null && !selected.contains("command_status"))
|
||||
{
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
if(selected != null && !selected.contains("id"))
|
||||
{
|
||||
@@ -72,7 +72,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
QRecord values = dependencies.getOrDefault(record.getValueString("id"), record);
|
||||
record.setValue("command_status", effective(values, now));
|
||||
}
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
result.put(row.getValueString("id"), row);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,24 +116,24 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
String status = record.getValueString("status");
|
||||
if(status != null && TERMINAL.contains(status))
|
||||
{
|
||||
return (status);
|
||||
return status;
|
||||
}
|
||||
if(!"PENDING".equals(status))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
String expiresAt = record.getValueString("expires_at");
|
||||
if(expiresAt == null || expiresAt.isBlank())
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Instant.parse(expiresAt).isBefore(now) ? "EXPIRED" : "PENDING");
|
||||
return Instant.parse(expiresAt).isBefore(now) ? "EXPIRED" : "PENDING";
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public final class PrintingControlAuthorization
|
||||
}
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null);
|
||||
PrintingProcesses.currentService();
|
||||
return (QContext.getQSession().getUser().getIdReference());
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Record is not accessible");
|
||||
}
|
||||
return (row);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ public final class PrintingControlAuthorization
|
||||
static QueryOutput query(QueryInput input) throws QException
|
||||
{
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input));
|
||||
return new QueryAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Printer is not assigned to this device");
|
||||
}
|
||||
return (row);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
job(deviceId, receipt.getValueString("job_id"));
|
||||
}
|
||||
return (service.deviceAction(deviceId, request, actor));
|
||||
return service.deviceAction(deviceId, request, actor);
|
||||
}
|
||||
Set<String> queue = null;
|
||||
if("CLEAR_QUEUE".equals(selected.wire))
|
||||
@@ -181,6 +181,6 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
job(deviceId, request.optString("jobId"));
|
||||
}
|
||||
return (service.deviceAction(deviceId, request, actor, queue));
|
||||
return service.deviceAction(deviceId, request, actor, queue);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import static com.kingsrook.qbits.printing.PrintingService.query;
|
||||
import static com.kingsrook.qbits.printing.PrintingService.update;
|
||||
|
||||
@@ -88,19 +89,19 @@ final class PrintingDeviceControls
|
||||
{
|
||||
result.put("lastPollAt", row.getString("control_last_poll_at"));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Strict action input and original receipt enforce replay before any side effect. */
|
||||
JSONObject action(String deviceId, JSONObject request, String actor) throws SQLException
|
||||
{
|
||||
return (action(deviceId, request, actor, null));
|
||||
return action(deviceId, request, actor, null);
|
||||
}
|
||||
|
||||
|
||||
/** A native authorized queue snapshot must still match after taking the device lock. */
|
||||
JSONObject action(String deviceId, JSONObject request, String actor, Set<String> authorizedQueue) throws SQLException
|
||||
JSONObject action(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
|
||||
{
|
||||
fields(request, Set.of("requestId", "issuedAt", "action", "jobId"));
|
||||
String requestId = text(request, "requestId", 128);
|
||||
@@ -132,7 +133,7 @@ final class PrintingDeviceControls
|
||||
throw new PrintingException(409, "Request identity was already used for a different action");
|
||||
}
|
||||
connection.commit();
|
||||
return (record(old.getFirst()));
|
||||
return record(old.getFirst());
|
||||
}
|
||||
Instant issued;
|
||||
try
|
||||
@@ -245,7 +246,7 @@ final class PrintingDeviceControls
|
||||
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
|
||||
JSONObject response = record(command(connection, deviceId, id));
|
||||
connection.commit();
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -301,8 +302,8 @@ final class PrintingDeviceControls
|
||||
commands.put(delivery);
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("controlVersion", 1).put("serverTime", now).put("pollIntervalSeconds", 5)
|
||||
.put("desiredState", state(device).getJSONObject("desiredState")).put("commands", commands));
|
||||
return new JSONObject().put("controlVersion", 1).put("serverTime", now).put("pollIntervalSeconds", 5)
|
||||
.put("desiredState", state(device).getJSONObject("desiredState")).put("commands", commands);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -375,7 +376,7 @@ final class PrintingDeviceControls
|
||||
update(connection, "UPDATE printing_device_command SET status=?,receipt_json=? WHERE id=? AND tenant_id=? AND device_id=?", status, receipt.toString(), commandId, tenant, deviceId);
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -387,7 +388,7 @@ final class PrintingDeviceControls
|
||||
|
||||
|
||||
/** Management history is bounded and remains readable after revocation. */
|
||||
JSONObject page(String deviceId, int limit, String before) throws SQLException
|
||||
JSONObject page(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -414,7 +415,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
commands.put(record(row));
|
||||
}
|
||||
return (new JSONObject().put("commands", commands).put("nextCursor", rows.size() > limit ? rows.get(limit - 1).getString("id") : JSONObject.NULL));
|
||||
return new JSONObject().put("commands", commands).put("nextCursor", rows.size() > limit ? rows.get(limit - 1).getString("id") : JSONObject.NULL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +425,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
try(Connection connection = source.getConnection())
|
||||
{
|
||||
return (record(command(connection, deviceId, commandId)));
|
||||
return record(command(connection, deviceId, commandId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +438,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(404, "Device not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -456,7 +457,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(404, "Command not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -486,14 +487,14 @@ final class PrintingDeviceControls
|
||||
{
|
||||
value.put("receipt", new JSONObject(row.getString("receipt_json")));
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
/** Numeric aggregates do not load a receipt history into application memory. */
|
||||
private static long count(Connection connection, String sql, Object... values) throws SQLException
|
||||
{
|
||||
return (query(connection, sql, values).getFirst().getLong("n"));
|
||||
return query(connection, sql, values).getFirst().getLong("n");
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +505,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + field);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -516,7 +517,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "UTC timestamp required");
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Require an exact nonnegative integral JSON value without coercion. */
|
||||
@@ -527,7 +528,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + field);
|
||||
}
|
||||
return (((Number) value).longValue());
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
if(action.process.equals(input.getProcessName()))
|
||||
{
|
||||
return (action);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
throw new QException("Unknown printing control action");
|
||||
@@ -182,7 +182,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
throw new QException("Sign in with an authorized QQQ user before controlling a print device");
|
||||
}
|
||||
return (QContext.getQSession().getUser().getIdReference());
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
throw new QException("Use Cancel queued jobs for work that has not been dispatched to Windows");
|
||||
}
|
||||
return (record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,10 +268,12 @@ public final class PrintingDeviceProcesses
|
||||
/************************************************************************
|
||||
** Standard summary links preserve the native record/table workflow.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,10 +344,12 @@ public final class PrintingDeviceProcesses
|
||||
/************************************************************************
|
||||
** Pending requests are never presented as completed Windows operations.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(applied, pending, unresolved)));
|
||||
return new ArrayList<>(List.of(applied, pending, unresolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -56,7 +57,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
Set<String> selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null;
|
||||
if(selected != null && !selected.contains("device_status") && !selected.contains("control_status"))
|
||||
{
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
if(selected != null && !selected.contains("id"))
|
||||
{
|
||||
@@ -76,7 +77,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
record.setValue("control_status", application(values));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +109,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
result.put(row.getValueString("id"), row);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,24 +121,24 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
{
|
||||
if(record.getValueInteger("enabled") != null && record.getValueInteger("enabled") == 0)
|
||||
{
|
||||
return ("REVOKED");
|
||||
return "REVOKED";
|
||||
}
|
||||
if("DISCONNECTED".equals(record.getValueString("connection_state")))
|
||||
{
|
||||
return ("DISCONNECTED");
|
||||
return "DISCONNECTED";
|
||||
}
|
||||
Instant heartbeat = timestamp(record.getValueString("last_seen_at"));
|
||||
Instant control = timestamp(record.getValueString("control_last_poll_at"));
|
||||
Instant seen = heartbeat == null || (control != null && control.isAfter(heartbeat)) ? control : heartbeat;
|
||||
if(seen == null || seen.isAfter(now.plusSeconds(30)))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
if(seen.isBefore(now.minusSeconds(90)))
|
||||
{
|
||||
return ("OFFLINE");
|
||||
return "OFFLINE";
|
||||
}
|
||||
return (Integer.valueOf(1).equals(record.getValueInteger("dispatch_paused")) ? "PAUSED" : "ONLINE");
|
||||
return Integer.valueOf(1).equals(record.getValueInteger("dispatch_paused")) ? "PAUSED" : "ONLINE";
|
||||
}
|
||||
|
||||
|
||||
@@ -149,11 +150,11 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
{
|
||||
if(!Integer.valueOf(1).equals(record.getValueInteger("control_version")))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
Long desired = record.getValueLong("control_revision");
|
||||
Long observed = record.getValueLong("control_applied_revision");
|
||||
return (desired != null && observed != null && desired.equals(observed) ? "APPLIED" : "PENDING");
|
||||
return desired != null && observed != null && desired.equals(observed) ? "APPLIED" : "PENDING";
|
||||
}
|
||||
|
||||
|
||||
@@ -161,19 +162,19 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
/***************************************************************************
|
||||
** Bad observations must remain unknown rather than imply a healthy device.
|
||||
***************************************************************************/
|
||||
private static Instant timestamp(String value)
|
||||
private static @Nullable Instant timestamp(@Nullable String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Instant.parse(value));
|
||||
return Instant.parse(value);
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,14 @@ public class PrintingDocumentStorage
|
||||
***************************************************************************/
|
||||
public byte[] get(String table, String reference) throws SQLException
|
||||
{
|
||||
return (get(table, reference, Math.toIntExact(PrintingRealtimePolicy.defaults().get("maxPdfBytes"))));
|
||||
return get(table, reference, Math.toIntExact(PrintingRealtimePolicy.defaults().get("maxPdfBytes")));
|
||||
}
|
||||
|
||||
|
||||
/** Internal retry callers supply the exact persisted length, independent of the current format policy. */
|
||||
public byte[] get(String table, String reference, int maximum) throws SQLException
|
||||
{
|
||||
return (execute(() ->
|
||||
return execute(() ->
|
||||
{
|
||||
try(InputStream stream = new StorageAction().getInputStream(input(table, reference)))
|
||||
{
|
||||
@@ -113,14 +113,14 @@ public class PrintingDocumentStorage
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** Return the native backend stream; the request owns closing it and enforcing its declared length. */
|
||||
public InputStream open(String table, String reference) throws SQLException
|
||||
{
|
||||
return (execute(() -> new StorageAction().getInputStream(input(table, reference))));
|
||||
return execute(() -> new StorageAction().getInputStream(input(table, reference)));
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
throw new IllegalArgumentException("Configured printing storage table does not exist");
|
||||
}
|
||||
return (instance.getTable(table).getBackendName());
|
||||
return instance.getTable(table).getBackendName();
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ public class PrintingDocumentStorage
|
||||
var backend = instance.getBackend(backendName(table));
|
||||
if(allowMemoryTest && "memory".equals(backend.getBackendType()))
|
||||
{
|
||||
return (digest(table + ":" + backend.getName()));
|
||||
return digest(table + ":" + backend.getName());
|
||||
}
|
||||
if(!table.startsWith("printing_") || !(backend instanceof S3BackendMetaData s3) || !(metadata.getBackendDetails() instanceof S3TableBackendDetails details)
|
||||
|| s3.getBucketName() == null || s3.getBucketName().isBlank())
|
||||
@@ -182,9 +182,9 @@ public class PrintingDocumentStorage
|
||||
throw new IllegalArgumentException("Printing bucket is shared with a non-printing table; configure a dedicated bucket");
|
||||
}
|
||||
}
|
||||
return (digest(new JSONArray().put(identity).put(java.util.Objects.toString(s3.getRegion(), "")).put(java.util.Objects.toString(s3.getPathStyleAccess(), "false"))
|
||||
return digest(new JSONArray().put(identity).put(java.util.Objects.toString(s3.getRegion(), "")).put(java.util.Objects.toString(s3.getPathStyleAccess(), "false"))
|
||||
.put(java.util.Objects.toString(s3.getBasePath(), "")).put(java.util.Objects.toString(details.getBasePath(), ""))
|
||||
.put(S3_KEY_LAYOUT).put((int) java.io.File.separatorChar).toString()));
|
||||
.put(S3_KEY_LAYOUT).put((int) java.io.File.separatorChar).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ public class PrintingDocumentStorage
|
||||
public String bucketName(String table)
|
||||
{
|
||||
var backend = instance.getBackend(backendName(table));
|
||||
return (backend instanceof S3BackendMetaData s3 ? s3.getBucketName() : "In-memory test storage");
|
||||
return backend instanceof S3BackendMetaData s3 ? s3.getBucketName() : "In-memory test storage";
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ public class PrintingDocumentStorage
|
||||
***************************************************************************/
|
||||
public String prefix()
|
||||
{
|
||||
return (prefix);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ public class PrintingDocumentStorage
|
||||
endpoint = uri.getScheme().toLowerCase(java.util.Locale.ROOT) + "://" + uri.getHost().toLowerCase(java.util.Locale.ROOT)
|
||||
+ (uri.getPort() < 0 || ("https".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 443) || ("http".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 80) ? "" : ":" + uri.getPort()) + java.util.Objects.toString(uri.getPath(), "").replaceAll("/+$", "");
|
||||
}
|
||||
return (new JSONArray().put(endpoint).put(java.util.Objects.toString(backend.getBucketName())).toString());
|
||||
return new JSONArray().put(endpoint).put(java.util.Objects.toString(backend.getBucketName())).toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))));
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException exception)
|
||||
{
|
||||
@@ -273,7 +273,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid owned printing storage reference");
|
||||
}
|
||||
return (new StorageInput(table).withReference(reference));
|
||||
return new StorageInput(table).withReference(reference);
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ public class PrintingDocumentStorage
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
return (operation.call());
|
||||
return operation.call();
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ final class PrintingDocumentStream extends FilterInputStream
|
||||
public int read() throws IOException
|
||||
{
|
||||
byte[] one = new byte[1];
|
||||
return (read(one, 0, 1) == -1 ? -1 : Byte.toUnsignedInt(one[0]));
|
||||
return read(one, 0, 1) == -1 ? -1 : Byte.toUnsignedInt(one[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,6 @@ final class PrintingDocumentStream extends FilterInputStream
|
||||
throw new IOException("Stored print document integrity check failed");
|
||||
}
|
||||
}
|
||||
return (read);
|
||||
return read;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public class PrintingException extends RuntimeException
|
||||
***************************************************************************/
|
||||
public int getStatus()
|
||||
{
|
||||
return (status);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,6 @@ public class PrintingExtract extends ExtractViaQueryStep
|
||||
count.setTableName(input.getValueString(FIELD_SOURCE_TABLE));
|
||||
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkTablePermissionThrowing(count, TablePermissionSubType.READ);
|
||||
return (super.doCount(input));
|
||||
return super.doCount(input);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -63,13 +64,13 @@ final class PrintingFacilities
|
||||
{
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName(DEFAULT_SOURCE).withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("default", "Default facility"))));
|
||||
return (DEFAULT_SOURCE);
|
||||
return DEFAULT_SOURCE;
|
||||
}
|
||||
if(instance.getPossibleValueSource(source) == null)
|
||||
{
|
||||
throw new QException("The configured printing facility possible-value source does not exist");
|
||||
}
|
||||
return (source);
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +81,8 @@ final class PrintingFacilities
|
||||
static QFieldMetaData field(QInstance instance)
|
||||
{
|
||||
String source = instance.getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName();
|
||||
return (new QFieldMetaData("facilityId", QFieldType.STRING).withLabel("Facility").withIsRequired(true).withMaxLength(200)
|
||||
.withPossibleValueSourceName(source).withDefaultValue(DEFAULT_SOURCE.equals(source) ? "default" : null));
|
||||
return new QFieldMetaData("facilityId", QFieldType.STRING).withLabel("Facility").withIsRequired(true).withMaxLength(200)
|
||||
.withPossibleValueSourceName(source).withDefaultValue(DEFAULT_SOURCE.equals(source) ? "default" : null);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ final class PrintingFacilities
|
||||
/***************************************************************************
|
||||
** Re-query the selected ID under the caller's session, never a system session.
|
||||
***************************************************************************/
|
||||
static String validate(String selected) throws QException
|
||||
static String validate(@Nullable String selected) throws QException
|
||||
{
|
||||
if(selected == null || selected.isBlank() || selected.length() > 200)
|
||||
{
|
||||
@@ -104,6 +105,6 @@ final class PrintingFacilities
|
||||
{
|
||||
throw new QException("The selected facility is unavailable or is not accessible to this user");
|
||||
}
|
||||
return (selected);
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
default -> throw new QException("Unknown printing home widget");
|
||||
};
|
||||
data.setSublabel("Checked " + DISPLAY_TIME.format(now) + " · current tenant · refresh every 60 seconds");
|
||||
return (new RenderWidgetOutput(data));
|
||||
return new RenderWidgetOutput(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, new QQueryFilter()))));
|
||||
}
|
||||
return (data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,10 +198,10 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
rows.add(issue(PRINTER, printer, printer.getValueString("name"), printer.getValueString("status"), "Check paper, paused/error state and whether this queue is still exposed by the agent.", "last_seen_at"));
|
||||
}
|
||||
return (table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
|
||||
return table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
|
||||
new TableData.Column("composite", "Issue / record", "issue", null, "left"),
|
||||
new TableData.Column("default", "Next step", "action", null, "left"),
|
||||
new TableData.Column("default", "Observed at", "observed", null, "left")));
|
||||
new TableData.Column("default", "Observed at", "observed", null, "left"));
|
||||
}
|
||||
|
||||
|
||||
@@ -219,12 +219,12 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
rows.add(Map.of("job", column().withBlock(text(job.getValueString("title")).withLink(new BlockLink(recordUrl(JOB, job)))),
|
||||
"status", job.getValueString("status"), "format", job.getValueString("format"), "created", displayTime(job.getValueString("created_at"))));
|
||||
}
|
||||
return (table(rows, "No pending or recent print work. Open Printers to enable an exposed queue and run its test-print action.",
|
||||
return table(rows, "No pending or recent print work. Open Printers to enable an exposed queue and run its test-print action.",
|
||||
new TableData.Column("composite", "Print job", "job", null, "left"),
|
||||
new TableData.Column("default", "Status", "status", null, "left"),
|
||||
new TableData.Column("default", "Format", "format", null, "left"),
|
||||
new TableData.Column("default", "Created at", "created", null, "left"))
|
||||
.withLinkText("Open all pending and recent jobs").withLinkURL(tableUrl(JOB, filter.withLimit(null))));
|
||||
.withLinkText("Open all pending and recent jobs").withLinkURL(tableUrl(JOB, filter.withLimit(null)));
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static StatisticsData statistic(String table, QQueryFilter filter, String context) throws QException
|
||||
{
|
||||
return (new StatisticsData().withCount(count(table, filter)).withCountContext(context).withCountURL(tableUrl(table, filter)));
|
||||
return new StatisticsData().withCount(count(table, filter)).withCountContext(context).withCountURL(tableUrl(table, filter));
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
QueryInput input = new QueryInput().withTableName(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
return new QueryAction().execute(input).getRecords();
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(input).getCount());
|
||||
return new CountAction().execute(input).getCount();
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Math.max(90, PrintingProcesses.currentService().realtimePolicy().get("heartbeatTimeoutSeconds")));
|
||||
return Math.max(90, PrintingProcesses.currentService().realtimePolicy().get("heartbeatTimeoutSeconds"));
|
||||
}
|
||||
catch(java.sql.SQLException exception)
|
||||
{
|
||||
@@ -286,7 +286,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
private static StatisticsData offlineStatistic(Instant now) throws QException
|
||||
{
|
||||
long seconds = freshnessSeconds();
|
||||
return (statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds"));
|
||||
return statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds");
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
QQueryFilter unseen = new QQueryFilter().withSubFilter(stale("last_seen_at", now, freshnessSeconds)).withSubFilter(stale("control_last_poll_at", now, freshnessSeconds));
|
||||
QQueryFilter disconnected = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("connection_state", QCriteriaOperator.EQUALS, "DISCONNECTED").withSubFilter(unseen);
|
||||
return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected));
|
||||
return new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected);
|
||||
}
|
||||
|
||||
|
||||
@@ -309,8 +309,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter stale(String field, Instant now, long freshnessSeconds)
|
||||
{
|
||||
return (new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(freshnessSeconds).toString()));
|
||||
return new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(freshnessSeconds).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -320,8 +320,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter printerIssues()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, "READY").withCriteria("available", QCriteriaOperator.NOT_EQUALS, 1)));
|
||||
return new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, "READY").withCriteria("available", QCriteriaOperator.NOT_EQUALS, 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter statuses(List<String> states)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, states));
|
||||
return new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, states);
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter failures(Instant now)
|
||||
{
|
||||
return (statuses(FAILURES).withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString()));
|
||||
return statuses(FAILURES).withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -351,8 +351,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> issue(String table, QRecord record, String label, String status, String action, String timestamp) throws QException
|
||||
{
|
||||
return (Map.of("issue", column().withBlock(text(label + " — " + status).withLink(new BlockLink(recordUrl(table, record)))),
|
||||
"action", action, "observed", displayTime(record.getValueString(timestamp))));
|
||||
return Map.of("issue", column().withBlock(text(label + " — " + status).withLink(new BlockLink(recordUrl(table, record)))),
|
||||
"action", action, "observed", displayTime(record.getValueString(timestamp)));
|
||||
}
|
||||
|
||||
|
||||
@@ -364,15 +364,15 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("Unknown time");
|
||||
return "Unknown time";
|
||||
}
|
||||
try
|
||||
{
|
||||
return (DISPLAY_TIME.format(Instant.parse(value)));
|
||||
return DISPLAY_TIME.format(Instant.parse(value));
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return ("Unknown time");
|
||||
return "Unknown time";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static TableData table(List<Map<String, Object>> rows, String empty, TableData.Column... columns)
|
||||
{
|
||||
return (new TableData(null, List.of(columns), rows).withRowsPerPage(25).withHidePaginationDropdown(true).withNoRowsFoundHTML(empty));
|
||||
return new TableData(null, List.of(columns), rows).withRowsPerPage(25).withHidePaginationDropdown(true).withNoRowsFoundHTML(empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static CompositeWidgetData column()
|
||||
{
|
||||
return (new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN));
|
||||
return new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN);
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static TextBlockData text(String value)
|
||||
{
|
||||
return (new TextBlockData().withValues(new TextValues(value)));
|
||||
return new TextBlockData().withValues(new TextValues(value));
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static String recordUrl(String table, QRecord record) throws QException
|
||||
{
|
||||
return (QContext.getQInstance().getTablePath(table) + "/" + URLEncoder.encode(record.getValueString("id"), StandardCharsets.UTF_8));
|
||||
return QContext.getQInstance().getTablePath(table) + "/" + URLEncoder.encode(record.getValueString("id"), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static String tableUrl(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (QContext.getQInstance().getTablePath(table) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8));
|
||||
return QContext.getQInstance().getTablePath(table) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public class PrintingJobProcesses
|
||||
{
|
||||
throw new QException("Job " + job.getValueString("title") + " is " + state + " and cannot be processed by this action");
|
||||
}
|
||||
return (job);
|
||||
return job;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ public class PrintingJobProcesses
|
||||
{
|
||||
throw new QException("Unknown print job action");
|
||||
}
|
||||
return (PrintingProcesses.currentService());
|
||||
return PrintingProcesses.currentService();
|
||||
}
|
||||
|
||||
|
||||
@@ -191,10 +191,12 @@ public class PrintingJobProcesses
|
||||
/***************************************************************************
|
||||
** Standard QQQ preview links selected job titles to their records.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready, risk)));
|
||||
return new ArrayList<>(List.of(ready, risk));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +253,12 @@ public class PrintingJobProcesses
|
||||
/***************************************************************************
|
||||
** Standard QQQ results refer to the selected source job records.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(done)));
|
||||
return new ArrayList<>(List.of(done));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,13 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -50,7 +52,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
private final PDType0Font font;
|
||||
private final PDRectangle size;
|
||||
private final String title;
|
||||
private PDPageContentStream content;
|
||||
private @Nullable PDPageContentStream content;
|
||||
private float y;
|
||||
private List<String> repeatHeader = List.of();
|
||||
private int repeatSize = 8;
|
||||
@@ -73,7 +75,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** With the app's header and footer on every page (defaults when null).
|
||||
***************************************************************************/
|
||||
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, PrintingChrome chrome) throws QException
|
||||
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome;
|
||||
try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE))
|
||||
@@ -111,7 +113,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
int columns(float fontSize)
|
||||
{
|
||||
return (Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize))));
|
||||
return Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize)));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +132,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** A note above the footer of every page (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
void footer(String text)
|
||||
void footer(@Nullable String text)
|
||||
{
|
||||
chrome = chrome.withFooterNote(text);
|
||||
}
|
||||
@@ -195,9 +197,9 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
void rule() throws IOException
|
||||
{
|
||||
content.moveTo(MARGIN, y);
|
||||
content.lineTo(size.getWidth() - MARGIN, y);
|
||||
content.stroke();
|
||||
activeContent().moveTo(MARGIN, y);
|
||||
activeContent().lineTo(size.getWidth() - MARGIN, y);
|
||||
activeContent().stroke();
|
||||
y -= 4;
|
||||
}
|
||||
|
||||
@@ -210,7 +212,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
{
|
||||
try
|
||||
{
|
||||
content.close();
|
||||
activeContent().close();
|
||||
content = null;
|
||||
int total = document.getNumberOfPages();
|
||||
for(int i = 0; i < total; i++)
|
||||
@@ -234,7 +236,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
return (output.toByteArray());
|
||||
return output.toByteArray();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -269,6 +271,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** Start a page; later pages show the running title.
|
||||
***************************************************************************/
|
||||
@EnsuresNonNull("content")
|
||||
private void newPage() throws IOException
|
||||
{
|
||||
if(document.getNumberOfPages() >= MAX_PAGES)
|
||||
@@ -281,9 +284,10 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
PDPage page = new PDPage(size);
|
||||
document.addPage(page);
|
||||
content = new PDPageContentStream(document, page);
|
||||
PDPageContentStream opened = new PDPageContentStream(document, page);
|
||||
content = opened;
|
||||
y = size.getHeight() - MARGIN + 14;
|
||||
header();
|
||||
header(opened);
|
||||
if(document.getNumberOfPages() > 1)
|
||||
{
|
||||
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
|
||||
@@ -297,25 +301,25 @@ final class PrintingLayout implements AutoCloseable
|
||||
** The app's header band: the app and server/user/time, then the title or
|
||||
** breadcrumb, then a rule.
|
||||
***************************************************************************/
|
||||
private void header() throws IOException
|
||||
private void header(PDPageContentStream page) throws IOException
|
||||
{
|
||||
String right = chrome.headerRight();
|
||||
int rightChars = Math.min(right.length(), columns(7.5f) / 2 + 10);
|
||||
String rightText = PrintingRecordDocuments.truncate(right, rightChars);
|
||||
y -= 9;
|
||||
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
|
||||
at(content, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f);
|
||||
at(page, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
|
||||
at(page, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f);
|
||||
if(chrome.title() != null && !chrome.title().isBlank())
|
||||
{
|
||||
y -= 10;
|
||||
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f);
|
||||
at(page, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f);
|
||||
}
|
||||
y -= 4;
|
||||
content.setLineWidth(0.5f);
|
||||
content.moveTo(MARGIN, y);
|
||||
content.lineTo(size.getWidth() - MARGIN, y);
|
||||
content.stroke();
|
||||
content.setLineWidth(1f);
|
||||
page.setLineWidth(0.5f);
|
||||
page.moveTo(MARGIN, y);
|
||||
page.lineTo(size.getWidth() - MARGIN, y);
|
||||
page.stroke();
|
||||
page.setLineWidth(1f);
|
||||
y -= 6;
|
||||
}
|
||||
|
||||
@@ -361,11 +365,24 @@ final class PrintingLayout implements AutoCloseable
|
||||
private void draw(String text, float fontSize, float step) throws IOException
|
||||
{
|
||||
y -= step;
|
||||
content.beginText();
|
||||
content.setFont(font, fontSize);
|
||||
content.newLineAtOffset(MARGIN, y);
|
||||
content.showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
|
||||
content.endText();
|
||||
activeContent().beginText();
|
||||
activeContent().setFont(font, fontSize);
|
||||
activeContent().newLineAtOffset(MARGIN, y);
|
||||
activeContent().showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
|
||||
activeContent().endText();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Construction/newPage opens content; drawing and finish precede its null
|
||||
** transition. Preserve the existing failure on use after finish, without
|
||||
** imposing a non-null precondition on pure footer formatting operations.
|
||||
***************************************************************************/
|
||||
@SuppressWarnings("NullAway")
|
||||
private PDPageContentStream activeContent()
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,7 +411,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
out.append('?');
|
||||
}
|
||||
});
|
||||
return (out.toString());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -404,7 +421,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
static int[] columnWidths(List<String> headers, List<List<String>> rows, int available)
|
||||
{
|
||||
return (fit(naturalWidths(headers, rows), available));
|
||||
return fit(naturalWidths(headers, rows), available);
|
||||
}
|
||||
|
||||
|
||||
@@ -428,7 +445,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
int typical = lengths.isEmpty() ? 0 : lengths.get(Math.max(0, (int) Math.ceil(lengths.size() * 0.9) - 1));
|
||||
widths[c] = Math.max(4, Math.min(60, Math.max(headers.get(c).length(), typical)));
|
||||
}
|
||||
return (widths);
|
||||
return widths;
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +455,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
static int lineWidth(int[] widths)
|
||||
{
|
||||
return (sum(widths) + Math.max(0, widths.length - 1) * 2);
|
||||
return sum(widths) + Math.max(0, widths.length - 1) * 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -465,7 +482,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
widths[widest]--;
|
||||
}
|
||||
return (widths);
|
||||
return widths;
|
||||
}
|
||||
|
||||
|
||||
@@ -480,7 +497,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
{
|
||||
total += value;
|
||||
}
|
||||
return (total);
|
||||
return total;
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +514,6 @@ final class PrintingLayout implements AutoCloseable
|
||||
cell = PrintingRecordDocuments.truncate(cell, widths[c]);
|
||||
out.append(c == 0 ? "" : " ").append(String.format("%-" + widths[c] + "s", cell));
|
||||
}
|
||||
return (out.toString().stripTrailing());
|
||||
return out.toString().stripTrailing();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMeta
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -125,14 +126,14 @@ public final class PrintingListProcesses
|
||||
fields.add(PrintingRecordProcesses.printerField());
|
||||
fields.add(new QFieldMetaData(PrintingRecordProcesses.COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1));
|
||||
fields.add(new QFieldMetaData("pageSize", QFieldType.STRING).withLabel("Paper").withPossibleValueSourceName("printing_pageSize").withDefaultValue("LETTER"));
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
|
||||
return new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("options").withLabel("Choose a printer").withFormFields(fields)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
|
||||
.withViewFields(List.of(new QFieldMetaData("result", QFieldType.STRING).withLabel("Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +169,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
throw new QException(table.getLabel() + " is not printable");
|
||||
}
|
||||
return (table);
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +207,7 @@ public final class PrintingListProcesses
|
||||
filter.setLimit(filter.getLimit() == null ? 50 : MAX_ROWS);
|
||||
}
|
||||
}
|
||||
return (filter);
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +221,7 @@ public final class PrintingListProcesses
|
||||
if(given == null || given.isBlank())
|
||||
{
|
||||
table.getFields().values().stream().filter(PrintingRecordProcesses::printable).forEach(out::add);
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
for(String name : Arrays.stream(given.split(",")).map(String::trim).filter(n -> !n.isEmpty()).toList())
|
||||
{
|
||||
@@ -238,7 +239,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
throw new QException("None of the columns can be printed");
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +269,7 @@ public final class PrintingListProcesses
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +281,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
if(filter.getOrderBys() == null)
|
||||
{
|
||||
return ("");
|
||||
return "";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for(var orderBy : filter.getOrderBys())
|
||||
@@ -289,7 +290,7 @@ public final class PrintingListProcesses
|
||||
QFieldMetaData field = name == null ? null : table.getFields().get(name);
|
||||
parts.add((field == null || field.getLabel() == null ? name : field.getLabel()) + (orderBy.getIsAscending() ? "" : " descending"));
|
||||
}
|
||||
return (String.join(", ", parts));
|
||||
return String.join(", ", parts);
|
||||
}
|
||||
|
||||
|
||||
@@ -297,19 +298,19 @@ public final class PrintingListProcesses
|
||||
/***************************************************************************
|
||||
** The grid's column widths ("12,30,8"), or null.
|
||||
***************************************************************************/
|
||||
static int[] widths(String text)
|
||||
static int @Nullable [] widths(@Nullable String text)
|
||||
{
|
||||
if(text == null || text.isBlank())
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray());
|
||||
return Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray();
|
||||
}
|
||||
catch(NumberFormatException exception)
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +322,7 @@ public final class PrintingListProcesses
|
||||
static String orientation(RunBackendStepInput input)
|
||||
{
|
||||
String value = input.getValueString("orientation");
|
||||
return (value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT));
|
||||
return value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
@@ -329,8 +330,54 @@ public final class PrintingListProcesses
|
||||
/***************************************************************************
|
||||
** A rendered document: its title, the PDF, and a note (rows left out).
|
||||
***************************************************************************/
|
||||
public record Rendered(String title, byte[] bytes, String message)
|
||||
public static final class Rendered
|
||||
{
|
||||
private final String title;
|
||||
private final byte[] bytes;
|
||||
private final @Nullable String message;
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Hold one finished document. The PDF array is handed over, not copied,
|
||||
** exactly as the former record component was.
|
||||
***********************************************************************/
|
||||
public Rendered(String title, byte[] bytes, @Nullable String message)
|
||||
{
|
||||
this.title = title;
|
||||
this.bytes = bytes;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** The document title.
|
||||
***********************************************************************/
|
||||
public String title()
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** The PDF bytes (the same array, not a copy).
|
||||
***********************************************************************/
|
||||
public byte[] bytes()
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** A note such as rows left out, or null when nothing was omitted.
|
||||
***********************************************************************/
|
||||
public @Nullable String message()
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -350,9 +397,9 @@ public final class PrintingListProcesses
|
||||
String sections = input.getValueString("sections");
|
||||
if(sections != null && !sections.isBlank())
|
||||
{
|
||||
return (new Rendered(title, PrintingTextDocuments.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null));
|
||||
return new Rendered(title, PrintingTextDocuments.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null);
|
||||
}
|
||||
return (new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), pageSize, landscape, chrome), null));
|
||||
return new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), pageSize, landscape, chrome), null);
|
||||
}
|
||||
QTableMetaData table = table(input);
|
||||
List<QFieldMetaData> columns = columns(table, input.getValueString("columns"));
|
||||
@@ -388,7 +435,7 @@ public final class PrintingListProcesses
|
||||
PrintingChrome.from(input, table.getLabel() == null ? table.getName() : table.getLabel()));
|
||||
String message = all && total != null && total > records.size()
|
||||
? "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest." : null;
|
||||
return (new Rendered(title, bytes, message));
|
||||
return new Rendered(title, bytes, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +465,7 @@ public final class PrintingListProcesses
|
||||
}
|
||||
out.add(new PrintingTextDocuments.Section(section.optString("title", ""), lines));
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
catch(org.json.JSONException exception)
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ public class PrintingLocalAuthentication extends TableBasedAuthenticationModule
|
||||
}
|
||||
session.setPermissions(Set.of("printing_admin"));
|
||||
session.withSecurityKeyValue("printing_tenant", metadata.tenant);
|
||||
return (session);
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.ForbiddenResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -117,7 +118,7 @@ public class PrintingLocalHost extends AbstractQQQApplication
|
||||
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service)
|
||||
.withSchedulerName(SCHEDULER)).produce(instance, "printing");
|
||||
mapPhysicalNames(instance);
|
||||
return (instance);
|
||||
return instance;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -191,7 +192,7 @@ public class PrintingLocalHost extends AbstractQQQApplication
|
||||
/***************************************************************************
|
||||
** Add native auth tables; an existing account is never implicitly reset.
|
||||
***************************************************************************/
|
||||
private void initializeAuthentication(String initialPassword) throws Exception
|
||||
private void initializeAuthentication(@Nullable String initialPassword) throws Exception
|
||||
{
|
||||
try(Connection connection = source.getConnection(); var statement = connection.createStatement())
|
||||
{
|
||||
|
||||
@@ -22,13 +22,14 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit local-host configuration. Never render credentials in diagnostics.
|
||||
*******************************************************************************/
|
||||
record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databasePassword, String tenant,
|
||||
int port, String administratorToken, String username, String initialPassword)
|
||||
int port, String administratorToken, String username, @Nullable String initialPassword)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Environment launch supports a durable local H2 file, never a remote bind.
|
||||
@@ -45,9 +46,9 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
{
|
||||
throw new IllegalArgumentException("QQQ_PRINT_PORT must be between 1 and 65535");
|
||||
}
|
||||
return (new PrintingLocalSettings(jdbc, environment.getOrDefault("QQQ_PRINT_DB_USER", "sa"),
|
||||
return new PrintingLocalSettings(jdbc, environment.getOrDefault("QQQ_PRINT_DB_USER", "sa"),
|
||||
environment.getOrDefault("QQQ_PRINT_DB_PASSWORD", ""), environment.getOrDefault("QQQ_PRINT_TENANT", "demo"), port,
|
||||
required(environment, "QQQ_PRINT_ADMIN_TOKEN"), required(environment, "QQQ_PRINT_UI_USERNAME"), environment.get("QQQ_PRINT_UI_INITIAL_PASSWORD")));
|
||||
required(environment, "QQQ_PRINT_ADMIN_TOKEN"), required(environment, "QQQ_PRINT_UI_USERNAME"), environment.get("QQQ_PRINT_UI_INITIAL_PASSWORD"));
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +78,7 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
{
|
||||
throw new IllegalArgumentException("Set " + name);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +89,6 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("PrintingLocalSettings[loopbackPort=" + port + ", credentials=redacted]");
|
||||
return "PrintingLocalSettings[loopbackPort=" + port + ", credentials=redacted]";
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -165,7 +166,7 @@ public final class PrintingOperatorAuthorization
|
||||
/***************************************************************************
|
||||
** Page through authorized rows; hidden parent devices never leak through joins.
|
||||
***************************************************************************/
|
||||
public static JSONObject catalog(int limit, String after) throws QException
|
||||
public static JSONObject catalog(int limit, @Nullable String after) throws QException
|
||||
{
|
||||
CallContext caller = capture();
|
||||
if(limit < 1 || limit > 200 || (after != null && !after.matches("[A-Za-z0-9._-]{1,200}")))
|
||||
@@ -311,18 +312,33 @@ public final class PrintingOperatorAuthorization
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exact instance identity for the captured caller authority. A replaced
|
||||
** instance, session, user or service must fail even when value equality
|
||||
** would hold, so this is deliberately not equals().
|
||||
***************************************************************************/
|
||||
// Identity of the captured authority objects is the security contract; equals() would weaken it.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private static boolean same(@Nullable Object captured, @Nullable Object current)
|
||||
{
|
||||
return captured == current;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Native customizers cannot replace the caller or its row-security scope.
|
||||
** Permissions and credential/channel ceilings are separately checked live.
|
||||
***************************************************************************/
|
||||
private record CallContext(QInstance instance, QSession session, QUser user, String actor, PrintingService service,
|
||||
Map<String, List<Serializable>> keys, Map<String, Serializable> variants)
|
||||
Map<String, List<Serializable>> keys, @Nullable Map<String, Serializable> variants)
|
||||
{
|
||||
/** Fail before consuming callback results under a different native authority. */
|
||||
void check() throws QException
|
||||
{
|
||||
if(instance != QContext.getQInstance() || session != QContext.getQSession() || user != session.getUser()
|
||||
|| !actor.equals(principal()) || service != PrintingProcesses.currentService()
|
||||
if(!same(instance, QContext.getQInstance()) || !same(session, QContext.getQSession()) || !same(user, session.getUser())
|
||||
|| !actor.equals(principal()) || !same(service, PrintingProcesses.currentService())
|
||||
|| !keys.equals(session.getSecurityKeyValues() == null ? Map.of() : session.getSecurityKeyValues())
|
||||
|| !Objects.equals(variants, session.getBackendVariants()))
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -46,7 +47,7 @@ public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPerm
|
||||
** Administrators retain submission access; operators receive no other role.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin")))
|
||||
{
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -119,9 +120,9 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData profileField(boolean pdf)
|
||||
{
|
||||
return (field("testProfile", "Test stock / printer resolution (match every selected printer)")
|
||||
return field("testProfile", "Test stock / printer resolution (match every selected printer)")
|
||||
.withPossibleValueSourceName(pdf ? "printing_testProfilePdf" : "printing_testProfileZpl")
|
||||
.withDefaultValue(pdf ? TestPrintProfile.LETTER.name() : TestPrintProfile.LABEL_4X6_203.name()));
|
||||
.withDefaultValue(pdf ? TestPrintProfile.LETTER.name() : TestPrintProfile.LABEL_4X6_203.name());
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +132,8 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReference(PrintingAdminPermission.class)));
|
||||
return new QPermissionRules().withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReference(PrintingAdminPermission.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +143,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData deviceField()
|
||||
{
|
||||
return (field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE));
|
||||
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +151,7 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Queue row IDs resolve to the agent's printer ID only after authorization.
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData printerField(String format)
|
||||
private static QFieldMetaData printerField(@Nullable String format)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"));
|
||||
if(format != null)
|
||||
@@ -159,8 +160,8 @@ public class PrintingProcesses implements BackendStep
|
||||
filter.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1));
|
||||
filter.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, format));
|
||||
}
|
||||
return (field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId")));
|
||||
return field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId"));
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +218,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData field(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true).withMaxLength("deviceName".equals(name) ? 1024 : 200));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true).withMaxLength("deviceName".equals(name) ? 1024 : 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +238,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("The current session cannot manage this printing tenant");
|
||||
}
|
||||
return (config.getService());
|
||||
return config.getService();
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured");
|
||||
@@ -289,7 +290,7 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Re-read records with the caller's permissions; form values are not trusted.
|
||||
***************************************************************************/
|
||||
static QRecord resolvePrinter(String id, String deviceId) throws QException
|
||||
static QRecord resolvePrinter(String id, @Nullable String deviceId) throws QException
|
||||
{
|
||||
GetInput get = new GetInput();
|
||||
get.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
@@ -300,7 +301,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new QException("Choose an accessible printer belonging to the selected device");
|
||||
}
|
||||
return (printer);
|
||||
return printer;
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +311,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
static String submitTest(QRecord printer, boolean pdf) throws QException
|
||||
{
|
||||
return (submitTest(printer, pdf, null));
|
||||
return submitTest(printer, pdf, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -318,9 +319,9 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** All built-in document entrypoints share profile validation and generation.
|
||||
***************************************************************************/
|
||||
static String submitTest(QRecord printer, boolean pdf, String profileName) throws QException
|
||||
static String submitTest(QRecord printer, boolean pdf, @Nullable String profileName) throws QException
|
||||
{
|
||||
return (submitTest(printer, pdf, profileName, "test:" + UUID.randomUUID()));
|
||||
return submitTest(printer, pdf, profileName, "test:" + UUID.randomUUID());
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +336,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new QException("Restart the test-print action to obtain a safe request identity");
|
||||
}
|
||||
return ("native-test:" + request + ":" + printer.getValueString("id"));
|
||||
return "native-test:" + request + ":" + printer.getValueString("id");
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +344,7 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Generate one immutable document and deduplicate replays of a process load.
|
||||
***************************************************************************/
|
||||
private static String submitTest(QRecord printer, boolean pdf, String profileName, String key) throws QException
|
||||
private static String submitTest(QRecord printer, boolean pdf, @Nullable String profileName, String key) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -351,7 +352,7 @@ public class PrintingProcesses implements BackendStep
|
||||
byte[] bytes = pdf ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
|
||||
JSONObject job = currentService().submit(new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
|
||||
.put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key));
|
||||
return (job.getString("jobId"));
|
||||
return job.getString("jobId");
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
@@ -476,10 +477,12 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Standard QQQ review summary.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,10 +532,12 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Standard QQQ result summary links back to the selected queues.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(done)));
|
||||
return new ArrayList<>(List.of(done));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +548,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
static byte[] testPdf() throws QException
|
||||
{
|
||||
return (PrintingTestDocuments.pdf());
|
||||
return PrintingTestDocuments.pdf();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -29,28 +30,35 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
*******************************************************************************/
|
||||
public class PrintingQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private transient PrintingService service;
|
||||
private @Nullable String backendName;
|
||||
private transient @Nullable PrintingService service;
|
||||
private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults();
|
||||
private PrintingRetentionPolicy retentionPolicy = new PrintingRetentionPolicy(true, 365, 200);
|
||||
private String storageTableName;
|
||||
private String schedulerName;
|
||||
private String facilityPossibleValueSourceName;
|
||||
private transient RetainedPrintSourceAuthority retainedSourceAuthority;
|
||||
private @Nullable String storageTableName;
|
||||
private @Nullable String schedulerName;
|
||||
private @Nullable String facilityPossibleValueSourceName;
|
||||
private transient @Nullable RetainedPrintSourceAuthority retainedSourceAuthority;
|
||||
|
||||
|
||||
/** Explicit trusted host integration; absent means retained submissions are disabled. */
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit trusted host integration; absent means retained submissions are disabled.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withRetainedSourceAuthority(RetainedPrintSourceAuthority value)
|
||||
{
|
||||
retainedSourceAuthority = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/** Never serialized into client metadata or supplied by a request. */
|
||||
public RetainedPrintSourceAuthority getRetainedSourceAuthority()
|
||||
|
||||
/***************************************************************************
|
||||
** Never serialized into client metadata or supplied by a request; null
|
||||
** means the host did not enable retained submissions.
|
||||
***************************************************************************/
|
||||
public @Nullable RetainedPrintSourceAuthority getRetainedSourceAuthority()
|
||||
{
|
||||
return (retainedSourceAuthority);
|
||||
return retainedSourceAuthority;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +68,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
***************************************************************************/
|
||||
public PrintingAdmissionLimits getAdmissionLimits()
|
||||
{
|
||||
return (admissionLimits);
|
||||
return admissionLimits;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +79,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withAdmissionLimits(PrintingAdmissionLimits value)
|
||||
{
|
||||
admissionLimits = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,10 +87,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Reuse the host's facility selector and its existing record security.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withFacilityPossibleValueSourceName(String value)
|
||||
public PrintingQBitConfig withFacilityPossibleValueSourceName(@Nullable String value)
|
||||
{
|
||||
facilityPossibleValueSourceName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +98,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Missing configuration uses the named standalone default facility.
|
||||
***************************************************************************/
|
||||
public String getFacilityPossibleValueSourceName()
|
||||
public @Nullable String getFacilityPossibleValueSourceName()
|
||||
{
|
||||
return (facilityPossibleValueSourceName);
|
||||
return facilityPossibleValueSourceName;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,10 +108,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Deployment-owned dedicated S3 table; operators cannot retarget old files.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withStorageTableName(String value)
|
||||
public PrintingQBitConfig withStorageTableName(@Nullable String value)
|
||||
{
|
||||
storageTableName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +119,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Existing storage metadata owns bucket, endpoint and credentials.
|
||||
***************************************************************************/
|
||||
public String getStorageTableName()
|
||||
public @Nullable String getStorageTableName()
|
||||
{
|
||||
return (storageTableName);
|
||||
return storageTableName;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,10 +129,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Bind bounded five-minute maintenance to a scheduler already configured by the host.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withSchedulerName(String value)
|
||||
public PrintingQBitConfig withSchedulerName(@Nullable String value)
|
||||
{
|
||||
schedulerName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,9 +140,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Missing host scheduler means no automatic background maintenance.
|
||||
***************************************************************************/
|
||||
public String getSchedulerName()
|
||||
public @Nullable String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
return schedulerName;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +153,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withRetentionPolicy(PrintingRetentionPolicy value)
|
||||
{
|
||||
retentionPolicy = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +163,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
***************************************************************************/
|
||||
public PrintingRetentionPolicy getRetentionPolicy()
|
||||
{
|
||||
return (retentionPolicy);
|
||||
return retentionPolicy;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +171,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Bind metadata reads to the host's existing RDBMS backend.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withBackendName(String value)
|
||||
public PrintingQBitConfig withBackendName(@Nullable String value)
|
||||
{
|
||||
backendName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +185,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withService(PrintingService value)
|
||||
{
|
||||
service = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,9 +193,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Return the table backend name.
|
||||
***************************************************************************/
|
||||
public String getBackendName()
|
||||
public @Nullable String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
return backendName;
|
||||
}
|
||||
|
||||
|
||||
@@ -195,9 +203,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Return the printing runtime without serializing credentials or connections.
|
||||
***************************************************************************/
|
||||
public PrintingService getService()
|
||||
public @Nullable PrintingService getService()
|
||||
{
|
||||
return (service);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMe
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.uber.nullaway.annotations.RequiresNonNull;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -61,7 +63,7 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
|
||||
*******************************************************************************/
|
||||
public class PrintingQBitProducer implements QBitProducer
|
||||
{
|
||||
private PrintingQBitConfig config;
|
||||
private @Nullable PrintingQBitConfig config;
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +73,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
public PrintingQBitProducer withConfig(PrintingQBitConfig value)
|
||||
{
|
||||
config = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +225,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
private static String version()
|
||||
{
|
||||
String version = PrintingQBitProducer.class.getPackage().getImplementationVersion();
|
||||
return (version == null || version.isBlank() ? "development" : version);
|
||||
return version == null || version.isBlank() ? "development" : version;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +233,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
/***************************************************************************
|
||||
** Produce read-only table metadata protected by tenant and administrator keys.
|
||||
***************************************************************************/
|
||||
@RequiresNonNull("config")
|
||||
private QTableMetaData table(String name, String label, List<String> columns)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName()).withPrimaryKeyField("id")
|
||||
@@ -243,7 +246,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
{
|
||||
table.withField(new QFieldMetaData(column, QFieldType.STRING).withLabel(column.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + column.substring(1).replace('_', ' ')));
|
||||
}
|
||||
return (table);
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +326,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("nextUi");
|
||||
return "nextUi";
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +337,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
public boolean includeInFullFrontendMetaData()
|
||||
{
|
||||
return (true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +347,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
***************************************************************************/
|
||||
public List<String> getDefaultColumns()
|
||||
{
|
||||
return (defaultColumns);
|
||||
return defaultColumns;
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +358,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,7 +42,7 @@ public class PrintingReadPermission extends PrintingAdminPermission
|
||||
** Require explicit printing administrator access and reject generic writes.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table
|
||||
&& (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName())))
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Host-owned commit notification bridge. Callbacks only schedule bounded work. */
|
||||
@@ -47,7 +48,7 @@ public class PrintingRealtimeBus
|
||||
public boolean owns(String tenant, String device, String generation)
|
||||
{
|
||||
Owner current = owners.get(tenant + ":" + device);
|
||||
return (current != null && current.generation().equals(generation));
|
||||
return current != null && current.generation().equals(generation);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +69,12 @@ public class PrintingRealtimeBus
|
||||
public AutoCloseable subscribe(BiConsumer<String, String> listener)
|
||||
{
|
||||
listeners.add(listener);
|
||||
return (() -> listeners.remove(listener));
|
||||
return () -> listeners.remove(listener);
|
||||
}
|
||||
|
||||
|
||||
/** Publish only after commit. Shared-host implementations must preserve this nonblocking contract. */
|
||||
public void publish(String tenant, String device)
|
||||
public void publish(String tenant, @Nullable String device)
|
||||
{
|
||||
for(BiConsumer<String, String> listener : listeners)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ public final class PrintingRealtimePolicy
|
||||
{
|
||||
JSONObject values = new JSONObject();
|
||||
FIELDS.forEach((name, range) -> values.put(name, range[0]));
|
||||
return (new PrintingRealtimePolicy(values));
|
||||
return new PrintingRealtimePolicy(values);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,21 +86,21 @@ public final class PrintingRealtimePolicy
|
||||
policy.atLeast("downloadTimeoutMillis", Math.max(policy.get("downloadConnectTimeoutMillis"), policy.get("downloadIdleTimeoutMillis")));
|
||||
policy.atLeast("heartbeatTimeoutSeconds", 2 * policy.get("heartbeatIntervalSeconds"));
|
||||
policy.atLeast("reconnectMaxDelayMillis", policy.get("reconnectMinDelayMillis"));
|
||||
return (policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
|
||||
/** Read a validated named setting without exposing mutable JSON. */
|
||||
public long get(String name)
|
||||
{
|
||||
return (values.getLong(name));
|
||||
return values.getLong(name);
|
||||
}
|
||||
|
||||
|
||||
/** Wire settings include a server-generated durable revision. */
|
||||
public JSONObject toJson(long revision)
|
||||
{
|
||||
return (new JSONObject(values.toString()).put("revision", revision));
|
||||
return new JSONObject(values.toString()).put("revision", revision);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ public final class PrintingRealtimePolicy
|
||||
{
|
||||
throw new PrintingException(400, "Only PDF and ZPL are supported");
|
||||
}
|
||||
return (get("PDF".equals(format) ? "maxPdfBytes" : "maxZplBytes"));
|
||||
return get("PDF".equals(format) ? "maxPdfBytes" : "maxZplBytes");
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,6 @@ public final class PrintingRealtimePolicy
|
||||
fields.put("reconnectMinDelayMillis", new long[] {1000, 100, 60000});
|
||||
fields.put("reconnectMaxDelayMillis", new long[] {30000, 1000, 300000});
|
||||
fields.put("reconnectJitterPercent", new long[] {20, 0, 50});
|
||||
return (java.util.Collections.unmodifiableMap(fields));
|
||||
return java.util.Collections.unmodifiableMap(fields);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(setting.unit())).longValueExact());
|
||||
return new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(setting.unit())).longValueExact();
|
||||
}
|
||||
catch(NumberFormatException | ArithmeticException exception)
|
||||
{
|
||||
@@ -209,7 +209,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static List<List<Setting>> groups(boolean admission)
|
||||
{
|
||||
return (admission ? List.of(ADMISSION_COUNTS, ADMISSION_STORAGE) : List.of(DOCUMENTS, TRANSFERS, CONNECTION, CONTROL));
|
||||
return admission ? List.of(ADMISSION_COUNTS, ADMISSION_STORAGE) : List.of(DOCUMENTS, TRANSFERS, CONNECTION, CONTROL);
|
||||
}
|
||||
|
||||
|
||||
@@ -219,10 +219,10 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData form(String name, String label, List<Setting> settings)
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName(name).withLabel(label)
|
||||
return new QFrontendStepMetaData().withName(name).withLabel(label)
|
||||
.withFormFields(settings.stream().map(setting -> new QFieldMetaData(setting.input(), setting.unit() == 1 ? QFieldType.INTEGER : QFieldType.DECIMAL)
|
||||
.withLabel(setting.label()).withIsRequired(true)).toList())
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QBackendStepMetaData backend(String name)
|
||||
{
|
||||
return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRealtimeProcesses.class)));
|
||||
return new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRealtimeProcesses.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData text(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsM
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import com.uber.nullaway.annotations.MonotonicNonNull;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpResponseException;
|
||||
@@ -50,6 +51,7 @@ import io.javalin.websocket.WsContext;
|
||||
import org.eclipse.jetty.websocket.api.Callback;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Current-user authenticated outbound channels. SQL is authoritative; notifications are only wakeups. */
|
||||
@@ -61,7 +63,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
private final Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final AutoCloseable subscription;
|
||||
private QInstance instance;
|
||||
private @MonotonicNonNull QInstance instance;
|
||||
|
||||
|
||||
/** Register this provider alongside the host's ordinary native QQQ endpoints. */
|
||||
@@ -143,10 +145,12 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
/** Resolve only the current socket generation. */
|
||||
|
||||
|
||||
private Channel current(WsContext context)
|
||||
// Identity is the socket-generation fence; value equality must never admit a replaced socket.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private @Nullable Channel current(WsContext context)
|
||||
{
|
||||
Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER));
|
||||
return (channel != null && channel.socket.session == context.session ? channel : null);
|
||||
return channel != null && channel.socket.session == context.session ? channel : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +215,22 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Revalidation requires the host instance already used during upgrade.
|
||||
** Check at use, so route registration ordering remains unchanged.
|
||||
***************************************************************************/
|
||||
private QInstance configuredInstance()
|
||||
{
|
||||
QInstance configured = instance;
|
||||
if(configured == null)
|
||||
{
|
||||
throw new IllegalStateException("The realtime route provider has no configured QQQ instance");
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Only transport credentials survive upgrade; user roles and session authority are never cached. */
|
||||
private static final class Credential
|
||||
{
|
||||
@@ -233,7 +253,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("Printing realtime credential [redacted]");
|
||||
return "Printing realtime credential [redacted]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +310,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
private final Credential credential;
|
||||
private final ArrayDeque<String> mailbox = new ArrayDeque<>();
|
||||
private PrintingRealtimePolicy policy = PrintingRealtimePolicy.defaults();
|
||||
private JSONObject hello;
|
||||
private @MonotonicNonNull JSONObject hello;
|
||||
private boolean running;
|
||||
private boolean dirty;
|
||||
private boolean stopped;
|
||||
@@ -315,6 +335,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
/** Re-enter the configured native authentication module for every operation after HTTP upgrade. */
|
||||
private void authorizeCurrent() throws Exception
|
||||
{
|
||||
QInstance instance = configuredInstance();
|
||||
QContext.init(instance, null);
|
||||
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
|
||||
var session = module.createSession(instance, Map.of(credential.cookieName, credential.cookie));
|
||||
@@ -339,8 +360,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
|
||||
private synchronized boolean ready(long revision)
|
||||
{
|
||||
return (!stopped && socket.session.isOpen() && service.getRealtimeBus().owns(service.getTenantId(), device, generation)
|
||||
&& hello != null && appliedState == sentState && appliedSettings == sentSettings && appliedSettings == revision);
|
||||
return !stopped && socket.session.isOpen() && service.getRealtimeBus().owns(service.getTenantId(), device, generation)
|
||||
&& hello != null && appliedState == sentState && appliedSettings == sentSettings && appliedSettings == revision;
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +439,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
authorizeCurrent();
|
||||
if(frame == null)
|
||||
{
|
||||
snapshot(false);
|
||||
snapshot(false, helloPayload());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -467,7 +488,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
throw new PrintingException(409, "Realtime capability is required");
|
||||
}
|
||||
hello = payload;
|
||||
snapshot(true);
|
||||
snapshot(true, payload);
|
||||
return;
|
||||
}
|
||||
case "STATE_APPLIED" ->
|
||||
@@ -478,7 +499,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
throw new PrintingException(409, "Only the current delivered state and settings may be acknowledged");
|
||||
}
|
||||
service.applyRealtimeState(device, controlRequest(state), settings);
|
||||
service.applyRealtimeState(device, controlRequest(helloPayload(), state), settings);
|
||||
synchronized(this)
|
||||
{
|
||||
appliedState = state;
|
||||
@@ -557,14 +578,30 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
stop("Realtime operation rejected");
|
||||
}
|
||||
return (connectionError);
|
||||
return connectionError;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Pass a validated immutable HELLO reference into subsequent operations.
|
||||
** Pre-HELLO operations keep the protocol's existing rejection contract.
|
||||
***************************************************************************/
|
||||
private JSONObject helloPayload()
|
||||
{
|
||||
JSONObject payload = hello;
|
||||
if(payload == null)
|
||||
{
|
||||
throw new PrintingException(409, "HELLO is required first");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Reuse the durable control protocol with advertised supported operations. */
|
||||
|
||||
|
||||
private JSONObject controlRequest(long state)
|
||||
private JSONObject controlRequest(JSONObject hello, long state)
|
||||
{
|
||||
JSONArray capabilities = new JSONArray();
|
||||
for(Object value : hello.getJSONArray("capabilities"))
|
||||
@@ -574,15 +611,15 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
capabilities.put(value);
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("controlVersion", 1).put("agentVersion", hello.getString("agentVersion"))
|
||||
.put("capabilities", capabilities).put("lastAppliedRevision", state));
|
||||
return new JSONObject().put("controlVersion", 1).put("agentVersion", hello.getString("agentVersion"))
|
||||
.put("capabilities", capabilities).put("lastAppliedRevision", state);
|
||||
}
|
||||
|
||||
|
||||
/** Reconstruct current state and pending commands after registration or commit. */
|
||||
|
||||
|
||||
private void snapshot(boolean initial) throws Exception
|
||||
private void snapshot(boolean initial, JSONObject hello) throws Exception
|
||||
{
|
||||
JSONObject settings = service.getRealtimeSettings();
|
||||
if(initial && integer(hello, "lastSettingsRevision") > settings.getLong("revision"))
|
||||
@@ -595,7 +632,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
policy.requireClientLimits(hello.getJSONObject("limits"));
|
||||
socket.session.setMaxTextMessageSize(policy.get("maxFrameBytes"));
|
||||
socket.session.setIdleTimeout(Duration.ofSeconds(policy.get("heartbeatTimeoutSeconds")));
|
||||
JSONObject state = service.deviceControlPoll(device, controlRequest(initial ? integer(hello, "lastAppliedRevision") : Math.max(0, appliedState)));
|
||||
JSONObject state = service.deviceControlPoll(device, controlRequest(hello, initial ? integer(hello, "lastAppliedRevision") : Math.max(0, appliedState)));
|
||||
PrintingControlAuthorization.table(PrintingTables.COMMAND);
|
||||
JSONObject desired = state.getJSONObject("desiredState");
|
||||
long previousState;
|
||||
@@ -689,6 +726,6 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
throw new PrintingException(400, "Expected nonnegative integer " + name);
|
||||
}
|
||||
return (number.longValue());
|
||||
return number.longValue();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -76,7 +77,7 @@ public final class PrintingRecordDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, List.of(new Section("", fields)), List.of()));
|
||||
return pdf(title, subtitle, List.of(new Section("", fields)), List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +87,7 @@ public final class PrintingRecordDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, sections, children, null));
|
||||
return pdf(title, subtitle, sections, children, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ public final class PrintingRecordDocuments
|
||||
/***************************************************************************
|
||||
** Render sections and child list summaries under the app's header/footer.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, PrintingChrome chrome) throws QException
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome))
|
||||
{
|
||||
@@ -143,7 +144,7 @@ public final class PrintingRecordDocuments
|
||||
layout.line("... and " + (child.total() - child.rows().size()) + " more", CHILD_SIZE);
|
||||
}
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -180,7 +181,7 @@ public final class PrintingRecordDocuments
|
||||
}
|
||||
lines.add(rest);
|
||||
}
|
||||
return (lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +192,6 @@ public final class PrintingRecordDocuments
|
||||
static String truncate(String text, int length)
|
||||
{
|
||||
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ');
|
||||
return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~");
|
||||
return value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~";
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -99,7 +100,7 @@ public final class PrintingRecordProcesses
|
||||
***************************************************************************/
|
||||
public static String processName(String tableName)
|
||||
{
|
||||
return (PREFIX + tableName);
|
||||
return PREFIX + tableName;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +155,7 @@ public final class PrintingRecordProcesses
|
||||
register(instance, table.getName());
|
||||
added.add(table.getName());
|
||||
}
|
||||
return (added);
|
||||
return added;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,8 +168,8 @@ public final class PrintingRecordProcesses
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
|
||||
return new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +190,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
|
||||
}
|
||||
return (printer);
|
||||
return printer;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,13 +203,13 @@ public final class PrintingRecordProcesses
|
||||
Integer copies = input.getValueInteger(COPIES_FIELD);
|
||||
if(copies == null)
|
||||
{
|
||||
return (1);
|
||||
return 1;
|
||||
}
|
||||
if(copies < 1 || copies > 100)
|
||||
{
|
||||
throw new QException("Copies must be between 1 and 100");
|
||||
}
|
||||
return (copies);
|
||||
return copies;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,11 +217,11 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Render one record (display values, visible non-binary fields) and queue it.
|
||||
***************************************************************************/
|
||||
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, PrintingChrome chrome) throws QException
|
||||
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
Serializable primaryKey = selected.getValue(table.getPrimaryKeyField());
|
||||
PrintingListProcesses.Rendered rendered = render(table, primaryKey, chrome);
|
||||
return (queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")));
|
||||
return queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +229,7 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** The record sheet of one record (read with the caller's permissions).
|
||||
***************************************************************************/
|
||||
static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, PrintingChrome chrome) throws QException
|
||||
static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
GetInput get = new GetInput();
|
||||
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
@@ -246,7 +247,7 @@ public final class PrintingRecordProcesses
|
||||
String subtitle = tableLabel + " " + primaryKey;
|
||||
PrintingChrome pageChrome = chrome == null ? PrintingChrome.defaults(tableLabel + " › " + label) : chrome.withTitleIfBlank(tableLabel + " › " + label);
|
||||
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record), pageChrome);
|
||||
return (new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null));
|
||||
return new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +258,7 @@ public final class PrintingRecordProcesses
|
||||
static String printedBy()
|
||||
{
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName();
|
||||
return ("printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user));
|
||||
return "printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,11 +266,11 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Display text of one field of a record (display value, else raw value).
|
||||
***************************************************************************/
|
||||
static String text(QRecord record, QFieldMetaData field)
|
||||
static @Nullable String text(QRecord record, QFieldMetaData field)
|
||||
{
|
||||
String display = record.getDisplayValue(field.getName());
|
||||
Serializable raw = record.getValue(field.getName());
|
||||
return (display != null ? display : raw == null ? null : String.valueOf(raw));
|
||||
return display != null ? display : raw == null ? null : String.valueOf(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,9 +278,9 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Whether a field belongs on paper: visible and not binary.
|
||||
***************************************************************************/
|
||||
static boolean printable(QFieldMetaData field)
|
||||
static boolean printable(@Nullable QFieldMetaData field)
|
||||
{
|
||||
return (field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB);
|
||||
return field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,20 +290,22 @@ public final class PrintingRecordProcesses
|
||||
** view shows them; fields in no section follow under "Other fields". A
|
||||
** table without sections prints one unlabeled section of every field.
|
||||
***************************************************************************/
|
||||
// QQQ declares record-section display tiers in their required presentation order.
|
||||
@SuppressWarnings("EnumOrdinal")
|
||||
static List<PrintingRecordDocuments.Section> sections(QTableMetaData table, QRecord record)
|
||||
{
|
||||
List<PrintingRecordDocuments.Section> out = new ArrayList<>();
|
||||
java.util.Set<String> placed = new java.util.HashSet<>();
|
||||
List<com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
|
||||
sections.removeIf(section -> Boolean.TRUE.equals(section.getIsHidden()) || section.getFieldNames() == null || section.getFieldNames().isEmpty());
|
||||
sections.sort(java.util.Comparator.comparing(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
|
||||
sections.sort(java.util.Comparator.comparingInt(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
|
||||
for(var section : sections)
|
||||
{
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for(String name : section.getFieldNames())
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
if(printable(field) && placed.add(name))
|
||||
if(field != null && printable(field) && placed.add(name))
|
||||
{
|
||||
fields.put(field.getLabel() == null ? name : field.getLabel(), text(record, field));
|
||||
}
|
||||
@@ -321,7 +324,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest));
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +386,7 @@ public final class PrintingRecordProcesses
|
||||
// a child list the user may not read (or that cannot load) is left out of the sheet
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +404,7 @@ public final class PrintingRecordProcesses
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", idempotencyKey)
|
||||
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
|
||||
return (job.getString("jobId"));
|
||||
return job.getString("jobId");
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
@@ -464,10 +467,12 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Standard review summary.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +518,8 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Result summary: what was sent, and the job IDs to follow.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
@@ -521,7 +528,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs."));
|
||||
}
|
||||
return (lines);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,11 @@ public final class PrintingRenderProcesses
|
||||
output.addValue("serverFilePath", ProcessFileDownload.register(file));
|
||||
output.addValue("printingTitle", rendered.title());
|
||||
output.addValue("printingBytes", rendered.bytes().length);
|
||||
if(rendered.message() != null)
|
||||
String message = rendered.message();
|
||||
if(message != null)
|
||||
{
|
||||
output.addValue("printingTruncated", true);
|
||||
output.addValue("printingMessage", rendered.message().replace("Printed the first", "Saved the first").replace("the print limit", "the limit").replace("to print the rest", "to save the rest"));
|
||||
output.addValue("printingMessage", message.replace("Printed the first", "Saved the first").replace("the print limit", "the limit").replace("to print the rest", "to save the rest"));
|
||||
}
|
||||
}
|
||||
catch(IOException exception)
|
||||
|
||||
@@ -44,7 +44,7 @@ public record PrintingRetentionPolicy(boolean enabled, int days, int batchSize)
|
||||
***************************************************************************/
|
||||
public static PrintingRetentionPolicy disabled()
|
||||
{
|
||||
return (new PrintingRetentionPolicy(false, 365, 200));
|
||||
return new PrintingRetentionPolicy(false, 365, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QProcessMetaData manual(String name, String label)
|
||||
{
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare")));
|
||||
return new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare"));
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QBackendStepMetaData backend(String name)
|
||||
{
|
||||
return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRetentionProcesses.class)));
|
||||
return new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRetentionProcesses.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,9 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData review(String label)
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName("review").withLabel(label)
|
||||
return new QFrontendStepMetaData().withName("review").withLabel(label)
|
||||
.withViewFields(List.of(text("policy", "Retention policy"), text("storage", "Document storage"), text("schedule", "Automatic schedule"), text("progress", "Cleanup progress"), text("safety", "What cleanup does")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData result()
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
return new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData text(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
{
|
||||
throw new QPermissionDeniedException("The scheduled retention entrypoint requires the system session; use Run retention cleanup instead");
|
||||
}
|
||||
service = configuration().getService();
|
||||
service = configuredService();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -217,6 +217,22 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Capture the configured service for this invocation, retaining the existing
|
||||
** missing-configuration exception if the host has withdrawn it.
|
||||
***************************************************************************/
|
||||
private static PrintingService configuredService() throws QException
|
||||
{
|
||||
PrintingService service = configuration().getService();
|
||||
if(service == null)
|
||||
{
|
||||
throw new QException("Printing QBit is not configured");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Avoid static service caches and prevent a scheduler from crossing instance boundaries.
|
||||
***************************************************************************/
|
||||
@@ -226,7 +242,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
{
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
|
||||
{
|
||||
return (config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured");
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -192,7 +193,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
***************************************************************************/
|
||||
private String device(Context context) throws java.sql.SQLException
|
||||
{
|
||||
return (service.authenticate(bearer(context)));
|
||||
return service.authenticate(bearer(context));
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +217,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
public static String bearer(Context context)
|
||||
{
|
||||
String header = context.header("Authorization");
|
||||
return (header != null && header.startsWith("Bearer ") ? header.substring(7) : "");
|
||||
return header != null && header.startsWith("Bearer ") ? header.substring(7) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +234,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
throw new PrintingException(413, "Request is too large");
|
||||
}
|
||||
return (new JSONObject(context.body()));
|
||||
return new JSONObject(context.body());
|
||||
}
|
||||
|
||||
|
||||
@@ -247,14 +248,14 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
throw new PrintingException(413, "Control request is too large");
|
||||
}
|
||||
return (new JSONObject(context.body()));
|
||||
return new JSONObject(context.body());
|
||||
}
|
||||
|
||||
|
||||
/** Standard mutation acknowledgment. */
|
||||
private static JSONObject accepted()
|
||||
{
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +293,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
/***************************************************************************
|
||||
** Emit the shared protocol error envelope.
|
||||
***************************************************************************/
|
||||
private static void error(Context context, int status, String message)
|
||||
private static void error(Context context, int status, @Nullable String message)
|
||||
{
|
||||
context.status(status).contentType("application/json").result(new JSONObject().put("code", "PRINTING_" + status).put("message", message).toString());
|
||||
}
|
||||
@@ -308,7 +309,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
/************************************************************************
|
||||
** Execute the route's service operation.
|
||||
************************************************************************/
|
||||
JSONObject run() throws Exception;
|
||||
@Nullable JSONObject run() throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,13 @@ import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -50,9 +53,9 @@ public class PrintingService
|
||||
private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id";
|
||||
private final DataSource dataSource;
|
||||
private final String tenantId;
|
||||
private PrintingDocumentStorage documentStorage;
|
||||
private @Nullable PrintingDocumentStorage documentStorage;
|
||||
private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults();
|
||||
private String storageTableName;
|
||||
private @Nullable String storageTableName;
|
||||
private PrintingRetentionPolicy defaultRetentionPolicy = new PrintingRetentionPolicy(true, 365, 200);
|
||||
private PrintingRealtimeBus realtimeBus = new PrintingRealtimeBus();
|
||||
private RealtimeClaimGuard realtimeClaimGuard = device ->
|
||||
@@ -117,14 +120,14 @@ public class PrintingService
|
||||
public PrintingService withRealtimeBus(PrintingRealtimeBus bus)
|
||||
{
|
||||
realtimeBus = java.util.Objects.requireNonNull(bus);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/** Instance-owned notifications never contain credentials or document bytes. */
|
||||
public PrintingRealtimeBus getRealtimeBus()
|
||||
{
|
||||
return (realtimeBus);
|
||||
return realtimeBus;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +140,7 @@ public class PrintingService
|
||||
JSONObject row = query(connection, "SELECT realtime_policy_json, realtime_revision FROM printing_settings WHERE tenant_id=?", tenantId).getFirst();
|
||||
PrintingRealtimePolicy policy = row.isNull("realtime_policy_json") ? PrintingRealtimePolicy.defaults()
|
||||
: PrintingRealtimePolicy.fromJson(new JSONObject(row.getString("realtime_policy_json")));
|
||||
return (policy.toJson(row.getLong("realtime_revision")));
|
||||
return policy.toJson(row.getLong("realtime_revision"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ public class PrintingService
|
||||
}
|
||||
}
|
||||
realtimeBus.publish(tenantId, null);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +181,7 @@ public class PrintingService
|
||||
{
|
||||
JSONObject settings = getRealtimeSettings();
|
||||
settings.remove("revision");
|
||||
return (PrintingRealtimePolicy.fromJson(settings));
|
||||
return PrintingRealtimePolicy.fromJson(settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +192,7 @@ public class PrintingService
|
||||
public PrintingService withAdmissionLimits(PrintingAdmissionLimits limits)
|
||||
{
|
||||
admissionLimits = java.util.Objects.requireNonNull(limits);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +200,7 @@ public class PrintingService
|
||||
public JSONObject getAdmissionSettings() throws SQLException
|
||||
{
|
||||
ensureRetentionSettings();
|
||||
return (new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).get());
|
||||
return new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).get();
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +210,7 @@ public class PrintingService
|
||||
ensureRetentionSettings();
|
||||
JSONObject result = new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).save(request);
|
||||
realtimeBus.publish(tenantId, null);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +218,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bind a host-owned storage table before exposing this service to requests.
|
||||
***************************************************************************/
|
||||
public void configureStorage(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, String table) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
public void configureStorage(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, @Nullable String table) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -232,7 +235,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Package-scoped adapter seam supports storage failure tests without S3.
|
||||
***************************************************************************/
|
||||
void configureStorage(PrintingDocumentStorage storage, String table)
|
||||
void configureStorage(PrintingDocumentStorage storage, @Nullable String table)
|
||||
{
|
||||
if(table != null)
|
||||
{
|
||||
@@ -250,7 +253,7 @@ public class PrintingService
|
||||
public PrintingService withRetentionPolicy(PrintingRetentionPolicy policy)
|
||||
{
|
||||
defaultRetentionPolicy = java.util.Objects.requireNonNull(policy);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -382,26 +385,26 @@ public class PrintingService
|
||||
{
|
||||
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor);
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Native authorization supplies the exact reviewed queue identity set, never a predicate executed inside SQL locks. */
|
||||
JSONObject deviceAction(String deviceId, JSONObject request, String actor, Set<String> authorizedQueue) throws SQLException
|
||||
JSONObject deviceAction(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
|
||||
{
|
||||
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue);
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Resolve a retained action receipt without treating new queue contents as part of a replay. */
|
||||
String existingDeviceAction(String deviceId, String requestId) throws SQLException
|
||||
@Nullable String existingDeviceAction(String deviceId, String requestId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenantId, deviceId, requestId);
|
||||
return (rows.isEmpty() ? null : rows.getFirst().getString("id"));
|
||||
return rows.isEmpty() ? null : rows.getFirst().getString("id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +424,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(429, "The queue exceeds the bounded native review limit");
|
||||
}
|
||||
return (ids);
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,35 +432,35 @@ public class PrintingService
|
||||
/** Independently poll desired state and pending commands using device authentication. */
|
||||
public JSONObject deviceControlPoll(String deviceId, JSONObject request) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).poll(deviceId, request));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).poll(deviceId, request);
|
||||
}
|
||||
|
||||
|
||||
/** Persist one immutable device observation without inferring physical output. */
|
||||
public JSONObject deviceControlReceipt(String deviceId, String commandId, JSONObject receipt) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).receipt(deviceId, commandId, receipt));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).receipt(deviceId, commandId, receipt);
|
||||
}
|
||||
|
||||
|
||||
/** Tenant/device scoped history remains visible after credential revocation. */
|
||||
public JSONObject deviceCommandsPage(String deviceId, int limit, String before) throws SQLException
|
||||
public JSONObject deviceCommandsPage(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).page(deviceId, limit, before));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).page(deviceId, limit, before);
|
||||
}
|
||||
|
||||
|
||||
/** Read the exact durable receipt for a native or HTTP management action. */
|
||||
public JSONObject getDeviceCommand(String deviceId, String commandId) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).get(deviceId, commandId));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).get(deviceId, commandId);
|
||||
}
|
||||
|
||||
|
||||
/** The immutable tenant scope used by host authorization and native actions. */
|
||||
public String getTenantId()
|
||||
{
|
||||
return (tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
|
||||
@@ -502,7 +505,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject createEnrollment(String facilityId, String deviceName) throws SQLException
|
||||
{
|
||||
return (createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString()));
|
||||
return createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -559,7 +562,7 @@ public class PrintingService
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("enrollmentCode", code).put("expiresAt", expires));
|
||||
return new JSONObject().put("enrollmentCode", code).put("expiresAt", expires);
|
||||
}
|
||||
|
||||
|
||||
@@ -569,7 +572,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject enroll(JSONObject request) throws SQLException
|
||||
{
|
||||
return (enrollForUser(request, null));
|
||||
return enrollForUser(request, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -583,13 +586,13 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(401, "Enrollment code is invalid or expired");
|
||||
}
|
||||
return (rows.getFirst().getString("facility_id"));
|
||||
return rows.getFirst().getString("facility_id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Native enrollment binds the core-authenticated principal in the credential transaction. */
|
||||
public JSONObject enrollForUser(JSONObject request, String principalId) throws SQLException
|
||||
public JSONObject enrollForUser(JSONObject request, @Nullable String principalId) throws SQLException
|
||||
{
|
||||
if(principalId != null && (principalId.isBlank() || principalId.length() > 200))
|
||||
{
|
||||
@@ -613,7 +616,7 @@ public class PrintingService
|
||||
update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name, bound_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName, principalId);
|
||||
update(connection, "UPDATE printing_enrollment SET consumed=1 WHERE code_hash=?", codeHash);
|
||||
connection.commit();
|
||||
return (new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5));
|
||||
return new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -637,7 +640,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(401, "Invalid device credential");
|
||||
}
|
||||
return (devices.getFirst().getString("id"));
|
||||
return devices.getFirst().getString("id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +715,7 @@ public class PrintingService
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("serverTime", now));
|
||||
return new JSONObject().put("serverTime", now);
|
||||
}
|
||||
|
||||
|
||||
@@ -727,7 +730,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** Native inventory changes exactly the existing row set authorized before opening this transaction. */
|
||||
void inventory(String deviceId, JSONArray printers, Set<String> authorizedRows) throws SQLException
|
||||
void inventory(String deviceId, JSONArray printers, @Nullable Set<String> authorizedRows) throws SQLException
|
||||
{
|
||||
if(printers.length() > 200)
|
||||
{
|
||||
@@ -824,14 +827,16 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject submit(JSONObject request) throws SQLException
|
||||
{
|
||||
return (submitAuthorized(request, null, null));
|
||||
return submitAuthorized(request, null, null);
|
||||
}
|
||||
|
||||
|
||||
/** Native operators retain current authority throughout shared queue admission. */
|
||||
JSONObject submitAuthorized(JSONObject request, String printerRowId, SubmissionAuthorization authorize) throws SQLException
|
||||
/***************************************************************************
|
||||
** Native operators retain current authority throughout shared queue admission.
|
||||
***************************************************************************/
|
||||
JSONObject submitAuthorized(JSONObject request, @Nullable String printerRowId, @Nullable SubmissionAuthorization authorize) throws SQLException
|
||||
{
|
||||
SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(printerRowId, authorize);
|
||||
SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(Objects.requireNonNull(printerRowId, "An authorized submission requires its printer row"), authorize);
|
||||
checkSubmission(guard);
|
||||
String format = required(request.optString("format"), "format");
|
||||
long maximum = realtimePolicy().documentLimit(format);
|
||||
@@ -860,23 +865,33 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "PDF header is missing");
|
||||
}
|
||||
return (submitPrepared(request, hash(content), content.length, connection -> content, null, guard));
|
||||
return submitPrepared(request, hash(content), content.length, connection -> content, null, guard);
|
||||
}
|
||||
|
||||
|
||||
/** A caller-owned current authorization check, never externally asserted proof. */
|
||||
|
||||
/***************************************************************************
|
||||
** A caller-owned current authorization check, never externally asserted proof.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface SubmissionAuthorization
|
||||
{
|
||||
/** Recheck caller identity, current ceilings and exact destination. */
|
||||
/***********************************************************************
|
||||
** Recheck caller identity, current ceilings and exact destination.
|
||||
***********************************************************************/
|
||||
void check() throws com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
}
|
||||
|
||||
|
||||
/** Native row identity and live authority are independent of retained-source proof. */
|
||||
|
||||
/***************************************************************************
|
||||
** Native row identity and live authority are independent of retained-source proof.
|
||||
***************************************************************************/
|
||||
private record SubmissionGuard(String printerRowId, SubmissionAuthorization authorize)
|
||||
{
|
||||
/** Host callbacks cannot retarget an authorized native row to another queue. */
|
||||
/***********************************************************************
|
||||
** Host callbacks cannot retarget an authorized native row to another queue.
|
||||
***********************************************************************/
|
||||
void requirePrinter(JSONObject row, String field)
|
||||
{
|
||||
if(!printerRowId.equals(row.optString(field)))
|
||||
@@ -887,8 +902,11 @@ public class PrintingService
|
||||
}
|
||||
|
||||
|
||||
/** Preserve checked native authorization through the SQL transaction boundary. */
|
||||
private static void checkSubmission(SubmissionGuard guard) throws SQLException
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve checked native authorization through the SQL transaction boundary.
|
||||
***************************************************************************/
|
||||
private static void checkSubmission(@Nullable SubmissionGuard guard) throws SQLException
|
||||
{
|
||||
if(guard != null)
|
||||
{
|
||||
@@ -924,7 +942,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException
|
||||
{
|
||||
return (submitPrepared(request, digest, contentLength, loader, null, null));
|
||||
return submitPrepared(request, digest, contentLength, loader, null, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -938,7 +956,7 @@ public class PrintingService
|
||||
}
|
||||
JSONObject source = binding.getJSONObject("source");
|
||||
BoundSubmission bound = new BoundSubmission(binding.toString(), actor);
|
||||
return (submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection ->
|
||||
return submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection ->
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -953,7 +971,7 @@ public class PrintingService
|
||||
{
|
||||
throw new SQLException("Retained content authorization failed", exception);
|
||||
}
|
||||
}, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run)));
|
||||
}, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run));
|
||||
}
|
||||
|
||||
|
||||
@@ -972,8 +990,10 @@ public class PrintingService
|
||||
}
|
||||
|
||||
|
||||
/** Reuse the existing storage reservation, queue admission, and commit path. */
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, BoundSubmission bound, SubmissionGuard guard) throws SQLException
|
||||
/***************************************************************************
|
||||
** Reuse the existing storage reservation, queue admission, and commit path.
|
||||
***************************************************************************/
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, @Nullable BoundSubmission bound, @Nullable SubmissionGuard guard) throws SQLException
|
||||
{
|
||||
checkSubmission(guard);
|
||||
String deviceId = required(request.optString("deviceId"), "deviceId");
|
||||
@@ -988,8 +1008,8 @@ public class PrintingService
|
||||
JSONObject options = printOptions(request);
|
||||
long contentLimit = realtimePolicy().documentLimit(format);
|
||||
String id = UUID.randomUUID().toString();
|
||||
String reference = storageTableName == null ? null : documentStorage.prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT);
|
||||
String binding = storageTableName == null ? null : documentStorage.binding(storageTableName);
|
||||
String reference = storageTableName == null ? "" : configuredStorage().prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT);
|
||||
String binding = storageTableName == null ? "" : configuredStorage().binding(storageTableName);
|
||||
String uploadedId = null;
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1030,7 +1050,7 @@ public class PrintingService
|
||||
JSONObject replay = bound == null ? job(old) : accepted(connection, old);
|
||||
checkSubmission(guard);
|
||||
connection.commit();
|
||||
return (replay);
|
||||
return replay;
|
||||
}
|
||||
if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty())
|
||||
{
|
||||
@@ -1099,10 +1119,10 @@ public class PrintingService
|
||||
checkSubmission(guard);
|
||||
if(storageTableName != null)
|
||||
{
|
||||
documentStorage.put(storageTableName, reference, format, content);
|
||||
configuredStorage().put(storageTableName, reference, format, content);
|
||||
}
|
||||
checkSubmission(guard);
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, reference, binding);
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
|
||||
if(bound != null)
|
||||
{
|
||||
String eventId = UUID.randomUUID().toString();
|
||||
@@ -1117,7 +1137,7 @@ public class PrintingService
|
||||
checkSubmission(guard);
|
||||
connection.commit();
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1130,10 +1150,7 @@ public class PrintingService
|
||||
}
|
||||
catch(Exception cleanupFailure)
|
||||
{
|
||||
if(cleanupFailure != exception)
|
||||
{
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
suppressIfDistinct(exception, cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw exception;
|
||||
@@ -1148,7 +1165,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject printOptions(JSONObject request) throws SQLException
|
||||
{
|
||||
return (printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies"))));
|
||||
return printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1192,7 +1209,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Print options are too large");
|
||||
}
|
||||
return (options);
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -1205,9 +1222,9 @@ public class PrintingService
|
||||
String format = required(request.optString("format"), "format");
|
||||
TestPrintProfile profile = TestPrintProfile.resolve(format, request.has("testProfile") ? request.optString("testProfile") : null);
|
||||
byte[] bytes = "PDF".equals(format) ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
|
||||
return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
|
||||
return submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
|
||||
.put("title", "QQQ print test").put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"))));
|
||||
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1225,7 +1242,7 @@ public class PrintingService
|
||||
{
|
||||
result.put(job(row));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1266,7 @@ public class PrintingService
|
||||
List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND idempotency_key=?", tenantId, deviceId, retryKey);
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
return (job(previous.getFirst()));
|
||||
return job(previous.getFirst());
|
||||
}
|
||||
if(row.getInt("content_available") == 0)
|
||||
{
|
||||
@@ -1271,10 +1288,10 @@ public class PrintingService
|
||||
{
|
||||
verifyStoredReference(row);
|
||||
}
|
||||
return (submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id"))
|
||||
return submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id"))
|
||||
.put("title", row.getString("title")).put("format", row.getString("format"))
|
||||
.put("options", new JSONObject(row.getString("options_json")))
|
||||
.put("idempotencyKey", retryKey), row.getString("content_hash"), row.getInt("content_length"), connection -> documentBytes(deviceJob(connection, deviceId, jobId, true))));
|
||||
.put("idempotencyKey", retryKey), row.getString("content_hash"), row.getInt("content_length"), connection -> documentBytes(deviceJob(connection, deviceId, jobId, true)));
|
||||
}
|
||||
|
||||
|
||||
@@ -1296,7 +1313,7 @@ public class PrintingService
|
||||
if("FAILED".equals(row.getString("status")) && CANCELED_BEFORE_DISPATCH.equals(row.optString("message")))
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
if(!"QUEUED".equals(row.getString("status")))
|
||||
{
|
||||
@@ -1305,7 +1322,7 @@ public class PrintingService
|
||||
update(connection, "UPDATE printing_job SET status='FAILED', message=? WHERE id=?", CANCELED_BEFORE_DISPATCH, jobId);
|
||||
connection.commit();
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1322,12 +1339,12 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject claim(String deviceId) throws SQLException
|
||||
{
|
||||
return (claimForJob(deviceId, null));
|
||||
return claimForJob(deviceId, null);
|
||||
}
|
||||
|
||||
|
||||
/** Native adapters authorize one exact job and printer before this locked dispatch transition. */
|
||||
public JSONObject claimForJob(String deviceId, String authorizedJobId) throws SQLException
|
||||
public JSONObject claimForJob(String deviceId, @Nullable String authorizedJobId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1341,13 +1358,13 @@ public class PrintingService
|
||||
if(control.getInt("dispatch_paused") == 1 || !"CONNECTED".equals(control.getString("connection_state")))
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
|
||||
if(candidates.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
JSONObject selected = candidates.getFirst();
|
||||
if(new JSONObject(selected.getString("options_json")).has("mediaProfile")
|
||||
@@ -1361,7 +1378,7 @@ public class PrintingService
|
||||
selected.put("status", "CLAIMED");
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", job(selected)));
|
||||
return new JSONObject().put("job", job(selected));
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1390,7 +1407,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(410, "Print document expired under the host retention policy");
|
||||
}
|
||||
return (documentBytes(row));
|
||||
return documentBytes(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,7 +1443,7 @@ public class PrintingService
|
||||
verifyStoredReference(row);
|
||||
input = documentStorage.open(row.getString("content_storage_table"), row.getString("content_reference"));
|
||||
}
|
||||
return (new PrintingDocumentStream(input, row.getLong("content_length"), row.getString("content_hash")));
|
||||
return new PrintingDocumentStream(input, row.getLong("content_length"), row.getString("content_hash"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1509,7 +1526,7 @@ public class PrintingService
|
||||
.put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")))
|
||||
.put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1).put("control", PrintingDeviceControls.state(row)));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,7 +1535,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bounded inventory history for management clients.
|
||||
***************************************************************************/
|
||||
public JSONObject printersPage(String deviceId, int limit, String before) throws SQLException
|
||||
public JSONObject printersPage(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -1562,7 +1579,7 @@ public class PrintingService
|
||||
String cursor = new JSONObject().put("name", last.getString("name")).put("id", last.getString("id")).toString();
|
||||
response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,7 +1590,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONArray printers(String deviceId) throws SQLException
|
||||
{
|
||||
return (printersPage(deviceId, 200, null).getJSONArray("printers"));
|
||||
return printersPage(deviceId, 200, null).getJSONArray("printers");
|
||||
}
|
||||
|
||||
|
||||
@@ -1583,7 +1600,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONArray jobs() throws SQLException
|
||||
{
|
||||
return (jobsPage(200, null).getJSONArray("jobs"));
|
||||
return jobsPage(200, null).getJSONArray("jobs");
|
||||
}
|
||||
|
||||
|
||||
@@ -1591,7 +1608,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Keyset pagination stays bounded as history grows, without document reads.
|
||||
***************************************************************************/
|
||||
public JSONObject jobsPage(int limit, String before) throws SQLException
|
||||
public JSONObject jobsPage(int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -1635,7 +1652,7 @@ public class PrintingService
|
||||
JSONObject cursor = new JSONObject().put("createdAt", last.getString("created_at")).put("jobId", last.getString("id"));
|
||||
response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,13 +1670,32 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
}
|
||||
return (job(rows.getFirst()));
|
||||
return job(rows.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Cleanup never replaces the acceptance/authorization failure that caused rollback. */
|
||||
/***************************************************************************
|
||||
** Record a secondary cleanup failure on the primary one. Throwable.addSuppressed
|
||||
** rejects the very same instance, so this is an identity check by design; value
|
||||
** equality would wrongly drop distinct failures that merely compare equal.
|
||||
***************************************************************************/
|
||||
// Identity is the Throwable self-suppression contract; equals() cannot express it.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private static void suppressIfDistinct(Throwable primary, Throwable secondary)
|
||||
{
|
||||
if(secondary != primary)
|
||||
{
|
||||
primary.addSuppressed(secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cleanup never replaces the acceptance/authorization failure that caused rollback.
|
||||
***************************************************************************/
|
||||
static void rollbackAfterFailure(Connection connection, Exception original)
|
||||
{
|
||||
try
|
||||
@@ -1668,16 +1704,13 @@ public class PrintingService
|
||||
}
|
||||
catch(Exception cleanupFailure)
|
||||
{
|
||||
if(cleanupFailure != original)
|
||||
{
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
suppressIfDistinct(original, cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Exact accepted-source lookup; unresolved object reservations never mean absence. */
|
||||
JSONObject findRetained(String key, JSONObject expected) throws SQLException
|
||||
@Nullable JSONObject findRetained(String key, @Nullable JSONObject expected) throws SQLException
|
||||
{
|
||||
if(expected != null)
|
||||
{
|
||||
@@ -1691,7 +1724,7 @@ public class PrintingService
|
||||
PrintingAdmission.lock(connection, tenantId);
|
||||
JSONObject result = findRetained(connection, key, expected);
|
||||
connection.commit();
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1703,7 +1736,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** The existing admission lock makes absence consistent with in-flight submissions. */
|
||||
private JSONObject findRetained(Connection connection, String key, JSONObject expected) throws SQLException
|
||||
private @Nullable JSONObject findRetained(Connection connection, String key, @Nullable JSONObject expected) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
if(rows.isEmpty())
|
||||
@@ -1719,7 +1752,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Accepted source binding differs");
|
||||
}
|
||||
return (accepted(connection, row));
|
||||
return accepted(connection, row);
|
||||
}
|
||||
|
||||
|
||||
@@ -1747,8 +1780,8 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Committed server acceptance event is absent or inconsistent");
|
||||
}
|
||||
return (new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id"))
|
||||
.put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding));
|
||||
return new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id"))
|
||||
.put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding);
|
||||
}
|
||||
|
||||
|
||||
@@ -1773,8 +1806,8 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Device observation is inconsistent");
|
||||
}
|
||||
return (new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at"))
|
||||
.put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId")));
|
||||
return new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at"))
|
||||
.put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1782,12 +1815,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Find an accepted request during durable-effect reconciliation.
|
||||
***************************************************************************/
|
||||
public JSONObject findByIdempotencyKey(String key) throws SQLException
|
||||
public @Nullable JSONObject findByIdempotencyKey(String key) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
return (rows.isEmpty() ? null : job(rows.getFirst()));
|
||||
return rows.isEmpty() ? null : job(rows.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,7 +1832,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** Read the exact committed row binding; a replacement queue cannot inherit history. */
|
||||
OperatorAcceptance findOperatorAcceptance(String key) throws SQLException
|
||||
@Nullable OperatorAcceptance findOperatorAcceptance(String key) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1828,7 +1861,7 @@ public class PrintingService
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
return (purgeCompletedContent(connection, policy, new int[1], null));
|
||||
return purgeCompletedContent(connection, policy, new int[1], null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,12 +1870,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Commit each confirmed removal so a later failure cannot undo its receipt.
|
||||
***************************************************************************/
|
||||
private int purgeCompletedContent(Connection connection, PrintingRetentionPolicy policy, int[] progress, String leaseToken) throws SQLException
|
||||
private int purgeCompletedContent(Connection connection, PrintingRetentionPolicy policy, int[] progress, @Nullable String leaseToken) throws SQLException
|
||||
{
|
||||
java.util.Objects.requireNonNull(policy);
|
||||
if(!policy.enabled())
|
||||
{
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
String cutoff = Instant.now().minusSeconds(policy.days() * 86400L).toString();
|
||||
{
|
||||
@@ -1877,7 +1910,7 @@ public class PrintingService
|
||||
progress[0] += changed;
|
||||
}
|
||||
connection.commit();
|
||||
return (purged);
|
||||
return purged;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1918,14 +1951,14 @@ public class PrintingService
|
||||
.put("lastRunError", row.optString("last_run_error")).put("eligibleBacklog", backlog);
|
||||
if(storageTableName != null)
|
||||
{
|
||||
result.put("storageTableName", storageTableName).put("storageBackendName", documentStorage.backendName(storageTableName))
|
||||
.put("bucketName", documentStorage.bucketName(storageTableName)).put("storagePrefix", documentStorage.prefix());
|
||||
result.put("storageTableName", storageTableName).put("storageBackendName", configuredStorage().backendName(storageTableName))
|
||||
.put("bucketName", configuredStorage().bucketName(storageTableName)).put("storagePrefix", configuredStorage().prefix());
|
||||
}
|
||||
else
|
||||
{
|
||||
result.put("storageMode", "LEGACY_DATABASE");
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,7 +2026,7 @@ public class PrintingService
|
||||
{
|
||||
if(update(connection, "UPDATE printing_settings SET lease_token=?, lease_expires_at=? WHERE tenant_id=? AND (lease_expires_at IS NULL OR lease_expires_at<?)", token, Instant.now().plusSeconds(3600).toString(), tenantId, Instant.now().toString()) == 0)
|
||||
{
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
@@ -2012,7 +2045,7 @@ public class PrintingService
|
||||
}
|
||||
update(connection, "UPDATE printing_settings SET last_run_at=?, last_run_count=?, last_run_error='', lease_token=NULL, lease_expires_at=NULL WHERE tenant_id=? AND lease_token=?", Instant.now().toString(), progress[0], tenantId, token);
|
||||
connection.commit();
|
||||
return (progress[0]);
|
||||
return progress[0];
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -2029,7 +2062,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Stop safely if another worker acquired an expired maintenance lease.
|
||||
***************************************************************************/
|
||||
private void renewRetentionLease(Connection connection, String token) throws SQLException
|
||||
private void renewRetentionLease(Connection connection, @Nullable String token) throws SQLException
|
||||
{
|
||||
if(token != null)
|
||||
{
|
||||
@@ -2066,7 +2099,20 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(502, "Stored print document integrity check failed");
|
||||
}
|
||||
return (bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** configureStorage installs the adapter before the optional table name,
|
||||
** before this service is exposed. Call only inside a non-null table branch;
|
||||
** inline storage does not require an adapter. No new runtime guard is added.
|
||||
***************************************************************************/
|
||||
@SuppressWarnings("NullAway")
|
||||
private PrintingDocumentStorage configuredStorage()
|
||||
{
|
||||
return documentStorage;
|
||||
}
|
||||
|
||||
|
||||
@@ -2074,6 +2120,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Historical table/bucket/namespace binding cannot be retargeted silently.
|
||||
***************************************************************************/
|
||||
@EnsuresNonNull("documentStorage")
|
||||
private void verifyStoredReference(JSONObject row)
|
||||
{
|
||||
if(documentStorage == null)
|
||||
@@ -2115,7 +2162,7 @@ public class PrintingService
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
JSONObject orphan = rows.getFirst();
|
||||
if(query(connection, "SELECT id FROM printing_job WHERE id=? AND tenant_id=?", id, tenantId).isEmpty())
|
||||
@@ -2136,7 +2183,7 @@ public class PrintingService
|
||||
}
|
||||
update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId);
|
||||
connection.commit();
|
||||
return (1);
|
||||
return 1;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -2153,7 +2200,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject deviceJob(Connection connection, String deviceId, String jobId) throws SQLException
|
||||
{
|
||||
return (deviceJob(connection, deviceId, jobId, false));
|
||||
return deviceJob(connection, deviceId, jobId, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -2168,7 +2215,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -2178,7 +2225,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private static JSONObject job(JSONObject row)
|
||||
{
|
||||
return (new JSONObject().put("jobId", row.getString("id")).put("deviceId", row.getString("device_id")).put("printerId", row.getString("printer_id")).put("title", row.getString("title")).put("format", row.getString("format")).put("contentSha256", row.getString("content_hash")).put("contentLength", row.getInt("content_length")).put("options", new JSONObject(row.getString("options_json"))).put("createdAt", row.getString("created_at")).put("status", row.getString("status")).put("message", row.optString("message")).put("spoolerJobId", row.optString("spooler_job_id")));
|
||||
return new JSONObject().put("jobId", row.getString("id")).put("deviceId", row.getString("device_id")).put("printerId", row.getString("printer_id")).put("title", row.getString("title")).put("format", row.getString("format")).put("contentSha256", row.getString("content_hash")).put("contentLength", row.getInt("content_length")).put("options", new JSONObject(row.getString("options_json"))).put("createdAt", row.getString("created_at")).put("status", row.getString("status")).put("message", row.optString("message")).put("spoolerJobId", row.optString("spooler_job_id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -2186,7 +2233,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Run a bounded, parameterized query with lower-case column names.
|
||||
***************************************************************************/
|
||||
static List<JSONObject> query(Connection connection, String sql, Object... values) throws SQLException
|
||||
static List<JSONObject> query(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -2203,7 +2250,7 @@ public class PrintingService
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2213,12 +2260,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Execute a parameterized mutation.
|
||||
***************************************************************************/
|
||||
static int update(Connection connection, String sql, Object... values) throws SQLException
|
||||
static int update(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
bind(statement, values);
|
||||
return (statement.executeUpdate());
|
||||
return statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2227,7 +2274,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bind values without SQL interpolation.
|
||||
***************************************************************************/
|
||||
private static void bind(PreparedStatement statement, Object[] values) throws SQLException
|
||||
private static void bind(PreparedStatement statement, @Nullable Object[] values) throws SQLException
|
||||
{
|
||||
for(int index = 0; index < values.length; index++)
|
||||
{
|
||||
@@ -2240,7 +2287,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Reject missing caller data before persistence.
|
||||
***************************************************************************/
|
||||
private static String required(String value, String name)
|
||||
private static String required(@Nullable String value, String name)
|
||||
{
|
||||
int maximum = switch(name)
|
||||
{
|
||||
@@ -2254,7 +2301,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -2268,7 +2315,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name);
|
||||
}
|
||||
return (optionalText(value, name, maximum));
|
||||
return optionalText(value, name, maximum);
|
||||
}
|
||||
|
||||
|
||||
@@ -2295,7 +2342,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name + "; maximum " + maximum + " characters");
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -2307,7 +2354,7 @@ public class PrintingService
|
||||
{
|
||||
byte[] bytes = new byte[32];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -2317,7 +2364,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private static String hash(String value)
|
||||
{
|
||||
return (hash(value.getBytes(StandardCharsets.UTF_8)));
|
||||
return hash(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
@@ -2329,7 +2376,7 @@ public class PrintingService
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)));
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException exception)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.printing;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -51,7 +52,7 @@ public final class PrintingTableDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null));
|
||||
return pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +64,7 @@ public final class PrintingTableDocuments
|
||||
** the columns do not fit portrait. The header repeats on every page; the
|
||||
** footer text goes at the bottom left of every page.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation, PrintingChrome chrome) throws QException
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String footer, List<String> columns, List<List<String>> rows, int @Nullable [] preferredWidths, String pageSize, String orientation, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
if(columns.isEmpty())
|
||||
{
|
||||
@@ -94,7 +95,7 @@ public final class PrintingTableDocuments
|
||||
layout.repeatOnNewPages(List.of(), FONT_SIZE);
|
||||
layout.gap(4);
|
||||
layout.line(rows.size() + (rows.size() == 1 ? " row" : " rows"), FONT_SIZE);
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -112,13 +113,13 @@ public final class PrintingTableDocuments
|
||||
{
|
||||
if("LANDSCAPE".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (true);
|
||||
return true;
|
||||
}
|
||||
if(!"AUTO".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (false);
|
||||
return false;
|
||||
}
|
||||
float width = ("A4".equalsIgnoreCase(pageSize) ? 595.28f : 612f) - 2 * PrintingLayout.MARGIN;
|
||||
return (PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE)));
|
||||
return PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,9 +42,9 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
/***************************************************************************
|
||||
** The settings of a table, or null when it has none.
|
||||
***************************************************************************/
|
||||
public static PrintingTableSettings of(QTableMetaData table)
|
||||
public static @Nullable PrintingTableSettings of(QTableMetaData table)
|
||||
{
|
||||
return (table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE));
|
||||
return table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +55,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
***************************************************************************/
|
||||
public boolean getPrintable()
|
||||
{
|
||||
return (printable);
|
||||
return printable;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +76,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
public PrintingTableSettings withPrintable(boolean value)
|
||||
{
|
||||
printable = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +87,6 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf() throws QException
|
||||
{
|
||||
return (pdf(TestPrintProfile.LETTER));
|
||||
return pdf(TestPrintProfile.LETTER);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ public final class PrintingTestDocuments
|
||||
}
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
document.save(bytes);
|
||||
return (bytes.toByteArray());
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -134,7 +134,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] zpl() throws QException
|
||||
{
|
||||
return (zpl(203));
|
||||
return zpl(203);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] zpl(TestPrintProfile profile) throws QException
|
||||
{
|
||||
return (zpl(profile.getDpi()));
|
||||
return zpl(profile.getDpi());
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ public final class PrintingTestDocuments
|
||||
appendGraphicStrips(zpl, bitmap, rowBytes, image.getHeight());
|
||||
zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module)
|
||||
.append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS^PQ1^XZ");
|
||||
return (zpl.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
return zpl.toString().getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
return (image);
|
||||
return image;
|
||||
}
|
||||
catch(IOException | FontFormatException exception)
|
||||
{
|
||||
@@ -261,7 +261,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
throw new IOException("Bundled test-document font is missing");
|
||||
}
|
||||
return (stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
return (image);
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
private static int dots(int points, int dpi)
|
||||
{
|
||||
return ((int) Math.round(points * dpi / 72.0));
|
||||
return (int) Math.round(points * dpi / 72.0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import java.io.IOException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -49,9 +50,9 @@ public final class PrintingTextDocuments
|
||||
/***************************************************************************
|
||||
** Render text on LETTER or A4, portrait or landscape.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape) throws QException
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String text, String pageSize, boolean landscape) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, text, pageSize, landscape, null));
|
||||
return pdf(title, subtitle, text, pageSize, landscape, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +60,7 @@ public final class PrintingTextDocuments
|
||||
/***************************************************************************
|
||||
** Render text under the app's header/footer.
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape, PrintingChrome chrome) throws QException
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String text, String pageSize, boolean landscape, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
if(text == null || text.isEmpty())
|
||||
{
|
||||
@@ -85,7 +86,7 @@ public final class PrintingTextDocuments
|
||||
}
|
||||
while(!rest.isEmpty());
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -107,7 +108,7 @@ public final class PrintingTextDocuments
|
||||
widest = Math.max(widest, line.stripTrailing().length());
|
||||
}
|
||||
float fits = charactersAtOnePoint / widest;
|
||||
return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2)));
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +161,7 @@ public final class PrintingTextDocuments
|
||||
}
|
||||
}
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class PrintingUiApi
|
||||
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade. */
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
|
||||
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
|
||||
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1.")
|
||||
.withSpec(Session.class)
|
||||
.withSpec(Bind.class)
|
||||
@@ -73,14 +73,19 @@ public final class PrintingUiApi
|
||||
.withSpec(Command.class)
|
||||
.withSpec(OperatorCatalog.class)
|
||||
.withSpec(OperatorSubmit.class)
|
||||
.withSpec(OperatorRequest.class));
|
||||
.withSpec(OperatorRequest.class);
|
||||
}
|
||||
|
||||
|
||||
/** Current-actor, approved/exposed printer catalog; no administrative permission. */
|
||||
|
||||
/***************************************************************************
|
||||
** Current-actor, approved/exposed printer catalog; no administrative permission.
|
||||
***************************************************************************/
|
||||
public static class OperatorCatalog extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorCatalog()
|
||||
{
|
||||
super("OperatorCatalog", HttpMethod.GET, "/operator/printers", null, "OperatorCatalog");
|
||||
@@ -88,10 +93,15 @@ public final class PrintingUiApi
|
||||
}
|
||||
|
||||
|
||||
/** Operator document submission uses the existing durable queue. */
|
||||
|
||||
/***************************************************************************
|
||||
** Operator document submission uses the existing durable queue.
|
||||
***************************************************************************/
|
||||
public static class OperatorSubmit extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorSubmit()
|
||||
{
|
||||
super("OperatorSubmit", HttpMethod.POST, "/operator/jobs", "OperatorSubmit", "Job");
|
||||
@@ -99,10 +109,15 @@ public final class PrintingUiApi
|
||||
}
|
||||
|
||||
|
||||
/** Exact actor/request lookup replaces general job history access. */
|
||||
|
||||
/***************************************************************************
|
||||
** Exact actor/request lookup replaces general job history access.
|
||||
***************************************************************************/
|
||||
public static class OperatorRequest extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorRequest()
|
||||
{
|
||||
super("OperatorRequest", HttpMethod.GET, "/operator/requests/{requestId}", null, "Job");
|
||||
@@ -210,7 +225,7 @@ public final class PrintingUiApi
|
||||
Map<Integer, Response> responses = new LinkedHashMap<>(super.defineResponses());
|
||||
responses.put(200, new Response().withDescription("Verified retained document bytes")
|
||||
.withContent(Map.of("application/octet-stream", new com.kingsrook.qqq.openapi.model.Content().withSchema(new Schema().withType(Type.STRING).withFormat("binary")))));
|
||||
return (responses);
|
||||
return responses;
|
||||
}
|
||||
|
||||
/** Do not JSON-serialize document contents or include them in logs. */
|
||||
|
||||
@@ -43,6 +43,7 @@ import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Native secured QQQ specs: session setup and credential scope execute before request construction. */
|
||||
@@ -51,12 +52,12 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
private final String operation;
|
||||
private final String path;
|
||||
private final HttpMethod method;
|
||||
private final String requestSchema;
|
||||
private final @Nullable String requestSchema;
|
||||
private final String responseSchema;
|
||||
|
||||
|
||||
/** Immutable operation metadata; request credentials never live on shared spec instances. */
|
||||
protected PrintingUiApiSpec(String operation, HttpMethod method, String path, String requestSchema, String responseSchema)
|
||||
protected PrintingUiApiSpec(String operation, HttpMethod method, String path, @Nullable String requestSchema, String responseSchema)
|
||||
{
|
||||
this.operation = operation;
|
||||
this.method = method;
|
||||
@@ -70,18 +71,18 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path)
|
||||
return new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path)
|
||||
.withOperationId("printing" + operation).withShortSummary("Printing " + operation)
|
||||
.withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority."
|
||||
: "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."));
|
||||
: "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.");
|
||||
}
|
||||
|
||||
|
||||
/** Typed request bodies use the same wire vocabulary as the native client. */
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
protected @Nullable Schema defineRequestBodySchema()
|
||||
{
|
||||
return (requestSchema == null ? null : PrintingApiSchemas.ref(requestSchema));
|
||||
return requestSchema == null ? null : PrintingApiSchemas.ref(requestSchema);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (PrintingApiSchemas.name(responseSchema));
|
||||
return PrintingApiSchemas.name(responseSchema);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +98,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (PrintingApiSchemas.components());
|
||||
return PrintingApiSchemas.components();
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +130,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50."));
|
||||
parameters.add(new Parameter().withName("after").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(200)));
|
||||
}
|
||||
return (parameters);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +169,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
input.getPathParams().put("_verifiedDevice", PrintingAgentAuthorization.device(id, token, "Bind".equals(operation)));
|
||||
}
|
||||
return (input);
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +182,11 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
JSONObject result = switch(operation)
|
||||
{
|
||||
case "OperatorCatalog" -> PrintingOperatorAuthorization.catalog(input.getQueryParamInteger("limit") == null ? 50 : input.getQueryParamInteger("limit"), input.getQueryParam("after"));
|
||||
case "OperatorCatalog" ->
|
||||
{
|
||||
Integer requestedLimit = input.getQueryParamInteger("limit");
|
||||
yield PrintingOperatorAuthorization.catalog(requestedLimit == null ? 50 : requestedLimit, input.getQueryParam("after"));
|
||||
}
|
||||
case "OperatorSubmit" -> PrintingOperatorAuthorization.submit(new JSONObject(input.getBody()));
|
||||
case "OperatorRequest" -> PrintingOperatorAuthorization.observe(input.getPathParam("requestId"));
|
||||
default -> throw new PrintingException(400, "Unknown operator operation");
|
||||
@@ -239,7 +244,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
case "Content" ->
|
||||
{
|
||||
PrintingControlAuthorization.job(device, job);
|
||||
return (service.openContent(device, job));
|
||||
return service.openContent(device, job);
|
||||
}
|
||||
case "Event" ->
|
||||
{
|
||||
@@ -305,7 +310,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
}
|
||||
default -> throw new PrintingException(400, "Unknown printing operation");
|
||||
}
|
||||
return (result instanceof JSONObject json ? json.toMap() : result);
|
||||
return result instanceof JSONObject json ? json.toMap() : result;
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +332,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
/** Mutation success does not assert a physical print outcome. */
|
||||
private static JSONObject accepted()
|
||||
{
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -355,12 +360,12 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()),
|
||||
return List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Native QQQ sign-in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission, device proof or user binding denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Record not found", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "State or request conflict", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.GONE, "Request or content expired", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Bounded capacity reached", BasicErrorResponseV1.class.getSimpleName())));
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Bounded capacity reached", BasicErrorResponseV1.class.getSimpleName()));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user