mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge remote-tracking branch 'origin/main' into prsync/7407
This commit is contained in:
@@ -119,8 +119,6 @@
|
||||
"extensions": [
|
||||
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
|
||||
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
|
||||
"ms-python.black-formatter", // Python code formatter using Black
|
||||
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
|
||||
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
|
||||
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
|
||||
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
|
||||
|
||||
@@ -19,9 +19,9 @@ import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
|
||||
@@ -133,11 +133,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
file_path = Path(file_path)
|
||||
language_dir = file_path.parent.name
|
||||
reference_lang_dir = reference_file.parent.name
|
||||
if (
|
||||
language_dir == reference_lang_dir
|
||||
or file_path.suffix != ".toml"
|
||||
or file_path.parents[1].name != "locales"
|
||||
):
|
||||
if language_dir == reference_lang_dir or file_path.suffix != ".toml" or file_path.parents[1].name != "locales":
|
||||
print(f"Skipping file: {file_path}")
|
||||
continue
|
||||
|
||||
@@ -198,9 +194,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.is_relative_to(base_dir):
|
||||
has_differences = True
|
||||
report.append(
|
||||
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
|
||||
)
|
||||
report.append(f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n")
|
||||
continue
|
||||
|
||||
# Verify file size before processing
|
||||
@@ -214,10 +208,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-US":
|
||||
continue
|
||||
|
||||
if (
|
||||
file_normpath.suffix != ".toml"
|
||||
or basename_current_file != "translation.toml"
|
||||
):
|
||||
if file_normpath.suffix != ".toml" or basename_current_file != "translation.toml":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
@@ -261,9 +252,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to remove them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
|
||||
)
|
||||
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`")
|
||||
report.append("")
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
@@ -271,9 +260,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to add them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
|
||||
)
|
||||
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`")
|
||||
report.append("")
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
@@ -288,9 +275,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
branch_path / file_normpath
|
||||
)
|
||||
for key, first, duplicate in find_duplicate_keys(branch_path / file_normpath)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
@@ -313,18 +298,14 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
report.append(f"Thanks @{actor} for your help in keeping the translations up to date.")
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find missing keys in TOML translation files"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
pip
|
||||
setuptools
|
||||
WeasyPrint
|
||||
pdf2image
|
||||
pillow
|
||||
unoserver
|
||||
opencv-python-headless
|
||||
pre-commit
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
@@ -1,528 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.13
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
|
||||
#
|
||||
# WARNING: pip install will require the following package to be hashed.
|
||||
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# fonttools
|
||||
cffi==2.1.0 \
|
||||
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
|
||||
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
|
||||
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
|
||||
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
|
||||
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
|
||||
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
|
||||
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
|
||||
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
|
||||
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
|
||||
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
|
||||
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
|
||||
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
|
||||
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
|
||||
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
|
||||
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
|
||||
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
|
||||
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
|
||||
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
|
||||
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
|
||||
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
|
||||
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
|
||||
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
|
||||
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
|
||||
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
|
||||
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
|
||||
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
|
||||
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
|
||||
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
|
||||
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
|
||||
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
|
||||
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
|
||||
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
|
||||
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
|
||||
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
|
||||
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
|
||||
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
|
||||
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
|
||||
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
|
||||
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
|
||||
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
|
||||
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
|
||||
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
|
||||
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
|
||||
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
|
||||
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
|
||||
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
|
||||
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
|
||||
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
|
||||
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
|
||||
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
|
||||
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
|
||||
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
|
||||
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
|
||||
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
|
||||
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
|
||||
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
|
||||
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
|
||||
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
|
||||
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
|
||||
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
|
||||
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
|
||||
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
|
||||
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
|
||||
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
|
||||
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
|
||||
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
|
||||
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
|
||||
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
|
||||
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
|
||||
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
|
||||
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
|
||||
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
|
||||
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
|
||||
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
|
||||
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
|
||||
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
|
||||
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
|
||||
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
|
||||
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
|
||||
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
|
||||
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
|
||||
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
|
||||
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
|
||||
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
|
||||
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
|
||||
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
|
||||
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
|
||||
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
|
||||
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
|
||||
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
|
||||
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
|
||||
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
|
||||
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
|
||||
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
|
||||
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
|
||||
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
|
||||
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
|
||||
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
|
||||
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
|
||||
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
|
||||
# via weasyprint
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
cssselect2==0.9.0 \
|
||||
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
|
||||
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
|
||||
# via weasyprint
|
||||
distlib==0.4.3 \
|
||||
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
|
||||
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
|
||||
# via virtualenv
|
||||
filelock==3.30.0 \
|
||||
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
|
||||
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
fonttools==4.63.0 \
|
||||
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
|
||||
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
|
||||
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
|
||||
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
|
||||
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
|
||||
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
|
||||
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
|
||||
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
|
||||
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
|
||||
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
|
||||
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
|
||||
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
|
||||
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
|
||||
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
|
||||
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
|
||||
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
|
||||
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
|
||||
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
|
||||
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
|
||||
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
|
||||
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
|
||||
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
|
||||
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
|
||||
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
|
||||
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
|
||||
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
|
||||
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
|
||||
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
|
||||
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
|
||||
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
|
||||
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
|
||||
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
|
||||
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
|
||||
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
|
||||
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
|
||||
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
|
||||
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
|
||||
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
|
||||
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
|
||||
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
|
||||
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
|
||||
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
|
||||
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
|
||||
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
|
||||
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
|
||||
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
|
||||
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
|
||||
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
|
||||
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
|
||||
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
|
||||
# via weasyprint
|
||||
identify==2.6.19 \
|
||||
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
|
||||
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
|
||||
# via pre-commit
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via opencv-python-headless
|
||||
opencv-python-headless==5.0.0.93 \
|
||||
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
|
||||
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
|
||||
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
|
||||
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
|
||||
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
|
||||
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
|
||||
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
|
||||
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
|
||||
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pdf2image==1.17.0 \
|
||||
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
||||
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pillow==12.3.0 \
|
||||
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
|
||||
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
|
||||
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
|
||||
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
|
||||
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
|
||||
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
|
||||
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
|
||||
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
|
||||
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
|
||||
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
|
||||
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
|
||||
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
|
||||
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
|
||||
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
|
||||
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
|
||||
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
|
||||
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
|
||||
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
|
||||
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
|
||||
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
|
||||
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
|
||||
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
|
||||
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
|
||||
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
|
||||
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
|
||||
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
|
||||
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
|
||||
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
|
||||
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
|
||||
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
|
||||
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
|
||||
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
|
||||
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
|
||||
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
|
||||
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
|
||||
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
|
||||
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
|
||||
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
|
||||
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
|
||||
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
|
||||
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
|
||||
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
|
||||
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
|
||||
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
|
||||
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
|
||||
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
|
||||
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
|
||||
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
|
||||
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
|
||||
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
|
||||
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
|
||||
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
|
||||
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
|
||||
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
|
||||
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
|
||||
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
|
||||
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
|
||||
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
|
||||
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
|
||||
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
|
||||
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
|
||||
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
|
||||
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
|
||||
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
|
||||
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
|
||||
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
|
||||
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
|
||||
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
|
||||
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
|
||||
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
|
||||
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
|
||||
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
|
||||
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
|
||||
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
|
||||
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
|
||||
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
|
||||
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
|
||||
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
|
||||
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
|
||||
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
|
||||
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
|
||||
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
|
||||
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
|
||||
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
|
||||
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
|
||||
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
|
||||
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# pdf2image
|
||||
# weasyprint
|
||||
platformdirs==4.10.0 \
|
||||
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
|
||||
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
pre-commit==4.6.0 \
|
||||
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
|
||||
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pycparser==3.0 \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pydyf==0.12.1 \
|
||||
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
|
||||
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
|
||||
# via weasyprint
|
||||
pyphen==0.17.2 \
|
||||
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
|
||||
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
|
||||
# via weasyprint
|
||||
python-discovery==1.4.4 \
|
||||
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
|
||||
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
|
||||
# via virtualenv
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
tinycss2==1.5.1 \
|
||||
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
|
||||
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
|
||||
# via
|
||||
# cssselect2
|
||||
# weasyprint
|
||||
tinyhtml5==2.1.0 \
|
||||
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
|
||||
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
|
||||
# via weasyprint
|
||||
unoserver==3.7 \
|
||||
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
|
||||
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
virtualenv==21.6.1 \
|
||||
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
|
||||
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
|
||||
# via pre-commit
|
||||
weasyprint==69.0 \
|
||||
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
|
||||
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
webencodings==0.5.1 \
|
||||
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
|
||||
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
|
||||
# via
|
||||
# cssselect2
|
||||
# tinycss2
|
||||
# tinyhtml5
|
||||
zopfli==0.4.3 \
|
||||
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
|
||||
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
|
||||
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
|
||||
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
|
||||
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
|
||||
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
|
||||
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
|
||||
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
|
||||
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
|
||||
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
|
||||
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
|
||||
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
|
||||
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
|
||||
# via fonttools
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==26.1.2 \
|
||||
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
|
||||
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
setuptools==83.0.0 \
|
||||
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
|
||||
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
@@ -1,2 +0,0 @@
|
||||
tomlkit
|
||||
tomli-w
|
||||
@@ -1,14 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.13
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
|
||||
#
|
||||
tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
tomlkit==0.15.0 \
|
||||
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
|
||||
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
@@ -4,19 +4,18 @@
|
||||
Usage: verify-updater-signatures.py <dir-to-scan> [tauri.conf.json]
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
ART_ROOT = Path(sys.argv[1])
|
||||
CONF = Path(
|
||||
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
|
||||
)
|
||||
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json")
|
||||
|
||||
|
||||
def load_pubkey():
|
||||
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
if: needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-engine
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: ai-engine
|
||||
|
||||
- name: Install Task
|
||||
@@ -96,6 +99,14 @@ jobs:
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Build engine production image
|
||||
if: always()
|
||||
run: docker build --file engine/Dockerfile --tag stirling-pdf-engine:ci .
|
||||
|
||||
- name: Build engine development image
|
||||
if: always()
|
||||
run: docker build --file engine/Dockerfile.dev --tag stirling-pdf-engine-dev:ci .
|
||||
|
||||
- name: Remove engine check comment on success
|
||||
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
|
||||
@@ -192,12 +192,14 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Install defusedxml for coverage summary
|
||||
# coverage-summary.py parses JaCoCo XML through defusedxml to
|
||||
# silence security scanners that pattern-match on the stdlib
|
||||
# xml.etree.ElementTree.parse call.
|
||||
- name: Install uv
|
||||
if: always() && matrix.flavor == 'saas'
|
||||
run: python -m pip install --quiet defusedxml
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
|
||||
- name: JaCoCo coverage step summary
|
||||
# Only the saas leg posts the JUnit summary - it's a strict
|
||||
@@ -206,7 +208,7 @@ jobs:
|
||||
# near-identical tables crowding out the aggregate report.
|
||||
if: always() && matrix.flavor == 'saas'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Backend JUnit coverage (JDK ${{ matrix.jdk-version }})" \
|
||||
--jacoco "common=app/common/build/reports/jacoco/test/jacocoTestReport.xml" \
|
||||
--jacoco "core=app/core/build/reports/jacoco/test/jacocoTestReport.xml" \
|
||||
|
||||
@@ -329,15 +329,16 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
- name: Install behave test deps
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
uv sync --project engine --locked --group cucumber
|
||||
- name: Build the multi-node image
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" build
|
||||
@@ -360,10 +361,10 @@ jobs:
|
||||
- name: Run multi-node regression (implemented guarantees)
|
||||
working-directory: testing/cucumber
|
||||
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
|
||||
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
|
||||
- name: Run multi-node failover (destructive)
|
||||
working-directory: testing/cucumber
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
|
||||
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
|
||||
- name: Dump node logs on failure
|
||||
if: failure()
|
||||
working-directory: testing/compose
|
||||
|
||||
@@ -34,6 +34,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
|
||||
@@ -195,20 +195,23 @@ jobs:
|
||||
console.log(`Reference file path: ${referenceFilePath}`);
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
uv sync --project engine --locked --group tools
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
run: |
|
||||
echo "Running Python script to check TOML files..."
|
||||
python .github/scripts/check_language_toml.py \
|
||||
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
|
||||
@@ -57,15 +57,13 @@ jobs:
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install defusedxml for coverage scripts
|
||||
# Both coverage-summary.py and coverage-matrix.py parse JaCoCo
|
||||
# XML through defusedxml - see the script headers for context.
|
||||
run: python -m pip install --quiet defusedxml
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
|
||||
# Pattern matches every artifact this PR's producers might upload:
|
||||
# jacoco-exec-junit-jdk-25 (uploaded only by the saas
|
||||
@@ -158,7 +156,7 @@ jobs:
|
||||
# ("how much of the backend do real user flows cover?").
|
||||
if: steps.inventory.outputs.found_e2e == 'true'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Real user-flow backend coverage (e2e:live + cucumber)" \
|
||||
--jacoco "merged=build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml" \
|
||||
--github-step-summary
|
||||
@@ -169,7 +167,7 @@ jobs:
|
||||
# is meaningless when one is a strict superset of the other.
|
||||
if: steps.inventory.outputs.found_all == 'true'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Combined backend coverage (JUnit + e2e:live + cucumber)" \
|
||||
--jacoco "merged=build/reports/jacoco/aggregate-all/jacocoTestReport.xml" \
|
||||
--github-step-summary
|
||||
@@ -224,7 +222,7 @@ jobs:
|
||||
# generated above) plus whichever frontend artifacts landed.
|
||||
# Every input is optional; missing ones render as "-".
|
||||
run: |
|
||||
python scripts/coverage-matrix.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-matrix.py \
|
||||
${{ steps.inventory.outputs.found_all == 'true' && '--jacoco-all build/reports/jacoco/aggregate-all/jacocoTestReport.xml' || '' }} \
|
||||
${{ steps.inventory.outputs.found_e2e == 'true' && '--jacoco-e2e build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml' || '' }} \
|
||||
--vitest matrix-inputs/vitest/coverage-summary.json \
|
||||
|
||||
@@ -69,16 +69,17 @@ jobs:
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
|
||||
- name: Pip requirements
|
||||
- name: Install Cucumber and coverage dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
uv sync --project engine --locked --group cucumber --group tools
|
||||
|
||||
- name: Extract JaCoCo agent for cucumber coverage
|
||||
# Stages build/jacoco/jacocoagent.jar where the coverage override
|
||||
@@ -121,16 +122,10 @@ jobs:
|
||||
echo "report=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install defusedxml for coverage summary
|
||||
# coverage-summary.py parses JaCoCo XML through defusedxml -
|
||||
# see the script header for context.
|
||||
if: always() && steps.cucumber-coverage.outputs.report == 'true'
|
||||
run: python -m pip install --quiet defusedxml
|
||||
|
||||
- name: Cucumber coverage step summary
|
||||
if: always() && steps.cucumber-coverage.outputs.report == 'true'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Cucumber (docker) JaCoCo coverage" \
|
||||
--jacoco "cucumber=build/reports/jacoco/cucumber/jacocoTestReport.xml" \
|
||||
--github-step-summary
|
||||
|
||||
@@ -119,20 +119,18 @@ jobs:
|
||||
echo "::warning::No e2e:live .exec found at .test-state/playwright/jacoco.exec; skipping report"
|
||||
echo "report=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Set up Python for coverage summary
|
||||
if: always() && steps.live-coverage.outputs.report == 'true'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
# coverage-summary.py uses defusedxml instead of stdlib xml.etree
|
||||
# to dodge XXE / billion-laughs scanner findings.
|
||||
if: always() && steps.live-coverage.outputs.report == 'true'
|
||||
run: python -m pip install --quiet defusedxml
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
- name: e2e:live coverage step summary
|
||||
if: always() && steps.live-coverage.outputs.report == 'true'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Playwright (live backend) JaCoCo coverage" \
|
||||
--jacoco "e2e-live=build/reports/jacoco/e2e-live/jacocoTestReport.xml" \
|
||||
--github-step-summary
|
||||
@@ -155,23 +153,6 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Set up Python for frontend coverage summary
|
||||
# Separate from the backend-coverage python step because the
|
||||
# frontend path doesn't depend on a JaCoCo report - it produces
|
||||
# a summary even on backend failure, as long as some Playwright
|
||||
# tests ran far enough to dump V8 coverage.
|
||||
if: always()
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install defusedxml for frontend coverage summary
|
||||
# Idempotent re-install: the backend-coverage step may have
|
||||
# installed it already, but this leg can run on its own when the
|
||||
# backend report step skips (e.g. .exec missing).
|
||||
if: always()
|
||||
run: python -m pip install --quiet defusedxml
|
||||
|
||||
- name: Aggregate Playwright frontend (V8) coverage
|
||||
# Rolls per-test V8 dumps from the test-base fixture into one
|
||||
# vitest-shaped coverage-summary.json. Tolerates a missing dump
|
||||
@@ -182,7 +163,7 @@ jobs:
|
||||
run: |
|
||||
if [ -d .test-state/playwright/coverage-pw ] && \
|
||||
find .test-state/playwright/coverage-pw -name '*.json' -type f | grep -q .; then
|
||||
python scripts/playwright-coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/playwright-coverage-summary.py \
|
||||
.test-state/playwright/coverage-pw \
|
||||
--out .test-state/playwright/coverage-pw-summary/coverage-summary.json
|
||||
echo "summary=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -194,10 +175,10 @@ jobs:
|
||||
- name: Playwright frontend coverage step summary
|
||||
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
--title "Playwright (live) frontend coverage" \
|
||||
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
|
||||
--github-step-summary
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Playwright (live) frontend coverage" \
|
||||
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
|
||||
--github-step-summary
|
||||
|
||||
- name: Upload Playwright frontend coverage
|
||||
# Bundle both the aggregated summary and the raw V8 dumps so
|
||||
|
||||
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
|
||||
|
||||
# Reusable workflow called from build.yml. Backend-free Playwright suite —
|
||||
# fast, no Spring Boot required. Runs against the `stubbed` project which
|
||||
# mocks API responses in the browser.
|
||||
# mocks API responses in the browser. Fans out one job per browser
|
||||
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -11,7 +12,19 @@ permissions:
|
||||
|
||||
jobs:
|
||||
playwright-e2e:
|
||||
name: playwright-e2e (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# One browser breaking must not mask a failure in another - report all.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- browser: chromium
|
||||
project: stubbed
|
||||
- browser: firefox
|
||||
project: stubbed-firefox
|
||||
- browser: webkit
|
||||
project: stubbed-webkit
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
@@ -27,16 +40,16 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Install Playwright (${{ matrix.browser }})
|
||||
run: task e2e:install -- ${{ matrix.browser }}
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
- name: Run stubbed E2E tests (${{ matrix.browser }})
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
@@ -50,6 +63,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -115,20 +115,18 @@ jobs:
|
||||
id: frontend-coverage
|
||||
continue-on-error: true
|
||||
run: task frontend:test:coverage
|
||||
- name: Set up Python for coverage summary
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
# See coverage-summary.py header - it parses XML through defusedxml
|
||||
# to dodge the stdlib parser's exposure to XXE / billion-laughs.
|
||||
if: always()
|
||||
run: python -m pip install --quiet defusedxml
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
- name: Vitest coverage step summary
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
|
||||
--title "Frontend Vitest coverage" \
|
||||
--vitest frontend/editor/coverage/coverage-summary.json \
|
||||
--github-step-summary
|
||||
|
||||
@@ -887,10 +887,16 @@ jobs:
|
||||
|
||||
# Gate publish on valid updater sigs. Runs after the review upload (so
|
||||
# artifacts survive for debugging) and before action-gh-release.
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
- name: Verify updater signatures
|
||||
run: |
|
||||
python3 -m pip install --quiet 'cryptography==44.0.0'
|
||||
python3 .github/scripts/verify-updater-signatures.py \
|
||||
uv run --project engine --locked --only-group updater-signatures python .github/scripts/verify-updater-signatures.py \
|
||||
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
|
||||
|
||||
# workflow_dispatch path requires platform=='all' so a single-platform
|
||||
|
||||
@@ -59,9 +59,10 @@ jobs:
|
||||
# the story itself — a shared component, a theme token — still surfaces within
|
||||
# a day.
|
||||
a11y-all-stories:
|
||||
name: a11y (every story)
|
||||
name: a11y (every story, light + dark)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Two full sweeps (one per theme), each ~30 minutes of browser time.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: a11y gate (every story)
|
||||
- name: a11y gate (every story, light + dark)
|
||||
run: task frontend:storybook:a11y
|
||||
|
||||
- name: Upload scan reports
|
||||
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -51,28 +51,25 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: sync-files
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --project engine --locked --group tools
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
|
||||
- name: Sort translation TOML files
|
||||
run: |
|
||||
@@ -85,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v3.py
|
||||
uv run --project engine --locked --group tools python scripts/counter_translation_v3.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
|
||||
@@ -15,6 +15,15 @@ tasks:
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
||||
|
||||
stubbed-project:
|
||||
desc: "Run the stubbed E2E suite for a single Playwright project"
|
||||
dir: frontend/editor
|
||||
deps: [ ':frontend:prepare' ]
|
||||
vars:
|
||||
PROJECT: '{{.PROJECT | default "stubbed"}}'
|
||||
cmds:
|
||||
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
|
||||
|
||||
live:
|
||||
desc: "Run live E2E tests"
|
||||
summary: |
|
||||
|
||||
+40
-13
@@ -2,22 +2,49 @@ version: '3'
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install engine dependencies"
|
||||
desc: "Install engine runtime and development dependencies"
|
||||
run: once
|
||||
cmds:
|
||||
- uv python install 3.13.8
|
||||
- uv sync
|
||||
- uv sync --locked --group engine --group engine-dev
|
||||
sources:
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
status:
|
||||
- test -d .venv
|
||||
|
||||
lock:
|
||||
desc: "Update the engine lockfile from project metadata"
|
||||
cmds:
|
||||
- uv lock
|
||||
|
||||
lock:upgrade:
|
||||
desc: "Upgrade allowed engine dependencies and update the lockfile"
|
||||
cmds:
|
||||
- uv lock --upgrade
|
||||
|
||||
lock:check:
|
||||
desc: "Check whether the engine lockfile is current"
|
||||
cmds:
|
||||
- uv lock --check
|
||||
|
||||
update:
|
||||
desc: "Upgrade engine dependencies and synchronize the environment"
|
||||
cmds:
|
||||
- task: lock:upgrade
|
||||
- uv sync --locked --group engine --group engine-dev
|
||||
|
||||
update:all:
|
||||
desc: "Upgrade all Python dependency groups and synchronize the environment"
|
||||
cmds:
|
||||
- task: lock:upgrade
|
||||
- uv sync --locked --all-groups
|
||||
|
||||
prepare:
|
||||
desc: "Set up engine .env from template"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run scripts/setup_env.py
|
||||
- uv run --locked --group engine --group engine-dev scripts/setup_env.py
|
||||
sources:
|
||||
- scripts/setup_env.py
|
||||
generates:
|
||||
@@ -33,7 +60,7 @@ tasks:
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
|
||||
- uv run --locked --group engine uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
|
||||
|
||||
dev:
|
||||
desc: "Start engine dev server with hot reload"
|
||||
@@ -45,43 +72,43 @@ tasks:
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
|
||||
- uv run --locked --group engine --group engine-dev uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
|
||||
|
||||
lint:
|
||||
desc: "Run linting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run ruff check .
|
||||
- uv run --locked --group engine --group engine-dev ruff check .
|
||||
|
||||
lint:fix:
|
||||
desc: "Auto-fix lint issues"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run ruff check . --fix
|
||||
- uv run --locked --group engine --group engine-dev ruff check . --fix
|
||||
|
||||
format:
|
||||
desc: "Auto-fix code formatting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run ruff format .
|
||||
- uv run --locked --group engine --group engine-dev ruff format .
|
||||
|
||||
format:check:
|
||||
desc: "Check code formatting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run ruff format . --diff
|
||||
- uv run --locked --group engine --group engine-dev ruff format . --diff
|
||||
|
||||
typecheck:
|
||||
desc: "Run type checking"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run pyright . --warnings
|
||||
- uv run --locked --group engine --group engine-dev pyright . --warnings
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- uv run pytest tests
|
||||
- uv run --locked --group engine --group engine-dev pytest tests
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix lint + format"
|
||||
@@ -102,7 +129,7 @@ tasks:
|
||||
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
|
||||
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
|
||||
sources:
|
||||
- ../SwaggerDoc.json
|
||||
- scripts/generate_tool_models.py
|
||||
@@ -114,7 +141,7 @@ tasks:
|
||||
desc: "Fail if the committed tool models are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
|
||||
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
|
||||
|
||||
clean:
|
||||
desc: "Clean build artifacts"
|
||||
|
||||
@@ -211,11 +211,13 @@ tasks:
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story: fail only on NEW axe violations"
|
||||
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
storybook:a11y:changed:
|
||||
desc: "a11y gate over the stories this branch affects (default base origin/main)"
|
||||
@@ -244,13 +246,17 @@ tasks:
|
||||
fi
|
||||
node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
storybook:a11y:record:
|
||||
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
|
||||
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
|
||||
+17
-13
@@ -45,6 +45,10 @@ vars:
|
||||
# which owns the version and caches the binary here.
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
env:
|
||||
# Keep repository-wide checks isolated from the engine runtime environment.
|
||||
UV_PROJECT_ENVIRONMENT: '.venv-pre-commit'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: "Check formatting, spelling, and secrets across the repo"
|
||||
@@ -76,19 +80,19 @@ tasks:
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
- uv sync --project engine --locked --group pre-commit
|
||||
sources:
|
||||
- scripts/pre-commit/uv.lock
|
||||
- scripts/pre-commit/pyproject.toml
|
||||
- engine/uv.lock
|
||||
- engine/pyproject.toml
|
||||
status:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
- test -d engine/.venv-pre-commit
|
||||
|
||||
clean:
|
||||
desc: "Remove the cached gitleaks binary and the tool virtualenv"
|
||||
desc: "Remove the cached gitleaks binary and the pre-commit virtualenv"
|
||||
cmds:
|
||||
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
|
||||
- cmd: rm -rf engine/.venv-pre-commit .task/bin/gitleaks
|
||||
platforms: [linux, darwin]
|
||||
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
|
||||
- cmd: cmd /c "rmdir /s /q engine\.venv-pre-commit & del /q .task\bin\gitleaks.exe"
|
||||
platforms: [windows]
|
||||
ignore_error: true
|
||||
|
||||
@@ -97,26 +101,26 @@ tasks:
|
||||
ruff:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
|
||||
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
ruff-format:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
|
||||
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
codespell:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
|
||||
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
|
||||
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
@@ -130,4 +134,4 @@ tasks:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
|
||||
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
|
||||
|
||||
Vendored
+1
-2
@@ -2,8 +2,6 @@
|
||||
"recommendations": [
|
||||
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
|
||||
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
|
||||
"ms-python.black-formatter", // Python code formatter using Black
|
||||
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
|
||||
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
|
||||
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
|
||||
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
|
||||
@@ -19,6 +17,7 @@
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"tamasfe.even-better-toml", // TOML language support and formatting
|
||||
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -20,8 +20,9 @@
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"ruff.configuration": "${workspaceFolder}/engine/pyproject.toml",
|
||||
"[gradle-kotlin-dsl]": {
|
||||
"editor.defaultFormatter": "vscjava.vscode-gradle"
|
||||
},
|
||||
|
||||
+14
-10
@@ -1,5 +1,6 @@
|
||||
package stirling.software.common.config.swagger;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.model.tool.ToolIOCase;
|
||||
import stirling.software.common.model.tool.ToolIOWhen;
|
||||
import stirling.software.common.service.ToolIOParameterDefaults;
|
||||
|
||||
/**
|
||||
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
|
||||
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
|
||||
if (declaration == null) {
|
||||
return operation;
|
||||
}
|
||||
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
|
||||
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
|
||||
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
|
||||
return operation;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toExtension(ToolIO declaration) {
|
||||
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
|
||||
Map<String, Object> extension = new LinkedHashMap<>();
|
||||
extension.put("accepts", names(declaration.accepts()));
|
||||
extension.put("produces", declaration.produces().name());
|
||||
extension.put("arity", declaration.arity().name());
|
||||
if (declaration.cases().length > 0) {
|
||||
extension.put("cases", cases(declaration));
|
||||
extension.put("cases", cases(declaration, handler));
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> cases(ToolIO declaration) {
|
||||
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
|
||||
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
|
||||
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
|
||||
}
|
||||
|
||||
private static Map<String, Object> toCase(ToolIOCase rule) {
|
||||
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put(
|
||||
"when",
|
||||
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
|
||||
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
|
||||
entry.put("produces", rule.produces().name());
|
||||
entry.put("arity", rule.arity().name());
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toCondition(ToolIOWhen condition) {
|
||||
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("param", condition.param());
|
||||
entry.put("matches", List.of(condition.matches()));
|
||||
// The default the endpoint uses when this parameter is absent, so a step that never sends
|
||||
// it still resolves. Omitted when the parameter is required with none.
|
||||
ToolIOParameterDefaults.resolve(handler, condition.param())
|
||||
.ifPresent(value -> entry.put("default", value));
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
|
||||
public record ToolIOSpec(
|
||||
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
|
||||
|
||||
public record When(String param, List<String> matches) {
|
||||
/**
|
||||
* @param paramDefault the value used when the parameter is absent, or null when it has no
|
||||
* default - an absent parameter then leaves the case unresolved rather than defaulted.
|
||||
*/
|
||||
public record When(String param, List<String> matches, String paramDefault) {
|
||||
|
||||
boolean holdsFor(Object value) {
|
||||
String normalised = normalise(value);
|
||||
@@ -19,6 +24,14 @@ public record ToolIOSpec(
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplies the default value a request parameter takes when a caller omits it. */
|
||||
@FunctionalInterface
|
||||
public interface ParameterDefaults {
|
||||
Optional<String> defaultFor(String param);
|
||||
|
||||
ParameterDefaults NONE = param -> Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sides of a condition are normalised at comparison, not at construction: the declaration
|
||||
* reaches the frontend and the engine as published data, and normalising only one side there
|
||||
@@ -44,25 +57,33 @@ public record ToolIOSpec(
|
||||
}
|
||||
|
||||
public static ToolIOSpec from(ToolIO annotation) {
|
||||
return from(annotation, ParameterDefaults.NONE);
|
||||
}
|
||||
|
||||
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
|
||||
return new ToolIOSpec(
|
||||
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
|
||||
annotation.produces(),
|
||||
annotation.arity(),
|
||||
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
|
||||
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
|
||||
}
|
||||
|
||||
private static Case toCase(ToolIOCase rule) {
|
||||
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
|
||||
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
|
||||
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
|
||||
return new Case(when, rule.produces(), rule.arity());
|
||||
}
|
||||
|
||||
private static When toWhen(ToolIOWhen condition) {
|
||||
return new When(condition.param(), List.of(condition.matches()));
|
||||
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
|
||||
return new When(
|
||||
condition.param(),
|
||||
List.of(condition.matches()),
|
||||
defaults.defaultFor(condition.param()).orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
|
||||
* declared output comes back uncertain: a value we never saw might have picked another branch.
|
||||
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
|
||||
* default; only a parameter with no default leaves the output uncertain, since an unseen value
|
||||
* might then have picked another branch.
|
||||
*
|
||||
* @param parameters the step's configured parameters, or null when not known
|
||||
*/
|
||||
@@ -71,12 +92,17 @@ public record ToolIOSpec(
|
||||
for (Case rule : cases) {
|
||||
boolean allHold = true;
|
||||
for (When condition : rule.when()) {
|
||||
if (parameters == null || !parameters.containsKey(condition.param())) {
|
||||
Object value;
|
||||
if (parameters != null && parameters.containsKey(condition.param())) {
|
||||
value = parameters.get(condition.param());
|
||||
} else if (condition.paramDefault() != null) {
|
||||
value = condition.paramDefault();
|
||||
} else {
|
||||
sawUnknownParam = true;
|
||||
allHold = false;
|
||||
continue;
|
||||
}
|
||||
allHold &= condition.holdsFor(parameters.get(condition.param()));
|
||||
allHold &= condition.holdsFor(value);
|
||||
}
|
||||
if (allHold) {
|
||||
return new Output(rule.produces(), rule.arity(), true);
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.Optional;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* The value a request parameter takes when the caller omits it, read from the request model so a
|
||||
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
|
||||
* on. The default is read from the field so it cannot drift.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ToolIOParameterDefaults {
|
||||
|
||||
// Swagger's sentinel for an unset @Schema string member; not a real default value.
|
||||
private static final String SCHEMA_UNSET = "##default";
|
||||
|
||||
private ToolIOParameterDefaults() {}
|
||||
|
||||
/**
|
||||
* The default {@code param} resolves to when absent, or empty when the parameter is required
|
||||
* with no declared default - in which case an unset value leaves the output genuinely unknown
|
||||
* rather than defaulted, and the chain reports it as uncertain.
|
||||
*
|
||||
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
|
||||
* primitive's language default, or an initializer), then the empty string for an optional field
|
||||
* left null, and finally empty for a required field with none of the above.
|
||||
*/
|
||||
public static Optional<String> resolve(Method handler, String param) {
|
||||
for (Parameter parameter : handler.getParameters()) {
|
||||
Field field = findField(parameter.getType(), param);
|
||||
if (field != null) {
|
||||
return fromField(parameter.getType(), field);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<String> fromField(Class<?> owner, Field field) {
|
||||
Schema schema = field.getAnnotation(Schema.class);
|
||||
if (schema != null
|
||||
&& !schema.defaultValue().isEmpty()
|
||||
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
|
||||
return Optional.of(schema.defaultValue());
|
||||
}
|
||||
Object value = readField(owner, field);
|
||||
if (value != null) {
|
||||
return Optional.of(String.valueOf(value));
|
||||
}
|
||||
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
|
||||
}
|
||||
|
||||
private static Object readField(Class<?> owner, Field field) {
|
||||
try {
|
||||
Object instance = owner.getDeclaredConstructor().newInstance();
|
||||
field.setAccessible(true);
|
||||
return field.get(instance);
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// A request model we cannot instantiate leaves the default unknown, which the check
|
||||
// treats conservatively as uncertain. Never break startup over it.
|
||||
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isRequired(Field field, Schema schema) {
|
||||
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
|
||||
return true;
|
||||
}
|
||||
return field.isAnnotationPresent(NotNull.class)
|
||||
|| field.isAnnotationPresent(NotBlank.class);
|
||||
}
|
||||
|
||||
private static Field findField(Class<?> type, String name) {
|
||||
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
try {
|
||||
return c.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
// Try the superclass; request models extend a shared file-input base.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
|
||||
if (annotation == null) {
|
||||
return;
|
||||
}
|
||||
ToolIOSpec spec = ToolIOSpec.from(annotation);
|
||||
Method method = handler.getMethod();
|
||||
ToolIOSpec spec =
|
||||
ToolIOSpec.from(
|
||||
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
|
||||
for (String pattern : extractPatterns(info)) {
|
||||
target.put(pattern, spec);
|
||||
}
|
||||
|
||||
+6
-1
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
|
||||
for (JsonNode match : condition.get("matches")) {
|
||||
matches.add(match.asString());
|
||||
}
|
||||
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
|
||||
JsonNode paramDefault = condition.get("default");
|
||||
when.add(
|
||||
new ToolIOSpec.When(
|
||||
condition.get("param").asString(),
|
||||
matches,
|
||||
paramDefault == null ? null : paramDefault.asString()));
|
||||
}
|
||||
cases.add(
|
||||
new ToolIOSpec.Case(
|
||||
|
||||
+20
@@ -61,6 +61,7 @@ public class PdfOverlayController {
|
||||
int overlayPos = request.getOverlayPosition();
|
||||
|
||||
MultipartFile[] overlayFiles = request.getOverlayFiles();
|
||||
validateOverlayFiles(overlayFiles);
|
||||
File[] overlayPdfFiles = new File[overlayFiles.length];
|
||||
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
|
||||
|
||||
@@ -120,10 +121,29 @@ public class PdfOverlayController {
|
||||
}
|
||||
}
|
||||
|
||||
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
|
||||
// caller omits them, which would otherwise surface as a 500 instead of a 400.
|
||||
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
|
||||
if (overlayFiles == null || overlayFiles.length == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFilesRequired", "At least one overlay file is required");
|
||||
}
|
||||
for (MultipartFile overlayFile : overlayFiles) {
|
||||
if (overlayFile == null || overlayFile.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFileEmpty", "Overlay files must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Integer, String> prepareOverlayGuide(
|
||||
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
|
||||
throws IOException {
|
||||
Map<Integer, String> overlayGuide = new HashMap<>();
|
||||
if (mode == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
|
||||
}
|
||||
switch (mode) {
|
||||
case "SequentialOverlay":
|
||||
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
|
||||
|
||||
@@ -16,6 +16,7 @@ To adjust the DPI resolution for rendering PDF pages:
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image
|
||||
|
||||
@@ -149,9 +150,7 @@ def main(pdf_image_path, output_dir, dpi=300, single_images_flag=False):
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Convert a PDF file to WebP images.")
|
||||
parser.add_argument("pdf_path", help="The path to the input PDF file.")
|
||||
parser.add_argument(
|
||||
"output_dir", help="The directory where the WebP images should be saved."
|
||||
)
|
||||
parser.add_argument("output_dir", help="The directory where the WebP images should be saved.")
|
||||
parser.add_argument(
|
||||
"--dpi",
|
||||
type=int,
|
||||
|
||||
+32
-1
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.model.tool.ToolIOSpec;
|
||||
import stirling.software.common.service.ToolIOParameterDefaults;
|
||||
|
||||
/**
|
||||
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
|
||||
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
|
||||
assertEquals(Set.of("ps", "pcl", "xps"), declared);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAbsentParameterResolvesToItsRequestModelDefault() {
|
||||
// A pipeline step often omits a parameter a case branches on. The default is read from the
|
||||
// request model, so the output resolves anyway instead of coming back uncertain.
|
||||
|
||||
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
|
||||
assertEquals(
|
||||
ToolFormat.PDF,
|
||||
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
|
||||
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
|
||||
|
||||
// Change Permissions posts to add-password with no password fields; both default to blank,
|
||||
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
|
||||
assertEquals(
|
||||
ToolFormat.PDF,
|
||||
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
|
||||
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
|
||||
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
|
||||
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
|
||||
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
|
||||
assertTrue(
|
||||
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
|
||||
}
|
||||
required.add(full);
|
||||
if (declaration != null) {
|
||||
declared.put(full, ToolIOSpec.from(declaration));
|
||||
declared.put(
|
||||
full,
|
||||
ToolIOSpec.from(
|
||||
declaration,
|
||||
param -> ToolIOParameterDefaults.resolve(method, param)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* "Seen, and I own it." Moves {@code NEW} to {@code ACKNOWLEDGED} so the row stops counting as
|
||||
* unread while staying in the open list. Touches only the status, not the document, ledger or run.
|
||||
*
|
||||
* <p>Re-acknowledging is a no-op that keeps the original actor and timestamp, so the first person
|
||||
* to pick it up stays credited.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AcknowledgeAction implements FailureAction {
|
||||
|
||||
private final FileRunEventStore store;
|
||||
|
||||
@Override
|
||||
public FailureActionId id() {
|
||||
return FailureActionId.ACKNOWLEDGE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
|
||||
// Guarded on NEW so a racing acknowledger cannot re-stamp the row, and so re-acknowledging
|
||||
// returns the first actor's row rather than taking their credit.
|
||||
return store.applyStatusOnce(
|
||||
event.id(),
|
||||
event.teamId(),
|
||||
FileRunEventStatus.ACKNOWLEDGED,
|
||||
actor,
|
||||
List.of(FileRunEventStatus.NEW));
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* "No remediation will happen; close it." Terminal. Named Dismiss rather than Reject because it
|
||||
* closes the incident, not the document: nothing is deleted or delivered, the ledger row is left
|
||||
* alone, and a different file failing the same way still opens its own incident.
|
||||
*
|
||||
* <p>Recurrences of this exact failure fold onto the dismissed row, which is what makes "stop
|
||||
* showing me this" hold for a source that re-lists the same failing file each sweep.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DismissAction implements FailureAction {
|
||||
|
||||
private final FileRunEventStore store;
|
||||
|
||||
@Override
|
||||
public FailureActionId id() {
|
||||
return FailureActionId.DISMISS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
|
||||
return store.applyStatus(event.id(), event.teamId(), FileRunEventStatus.DISMISSED, actor);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The behaviour behind one {@link FailureActionId}. Implementations are Spring beans injected as a
|
||||
* {@code List} and resolved by id, the pattern already used for {@code InputSource}, {@code
|
||||
* PolicyOutputSink} and {@code PolicyTrigger}.
|
||||
*
|
||||
* <p>Keeping behaviour out of the registry keeps that pure data, so a new kind ships by declaring
|
||||
* an action id that already has a handler.
|
||||
*/
|
||||
public interface FailureAction {
|
||||
|
||||
FailureActionId id();
|
||||
|
||||
/**
|
||||
* Apply the action and return the updated event. {@code inputs} carries whatever the action
|
||||
* declared it needs, which is nothing for the two that exist today. Implementations leave the
|
||||
* document, ledger, run and output destinations alone unless that is the action's purpose.
|
||||
*/
|
||||
FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
|
||||
* the service stays web-agnostic and the controller owns the mapping.
|
||||
*/
|
||||
@Getter
|
||||
public class FailureActionException extends RuntimeException {
|
||||
|
||||
public enum Reason {
|
||||
/**
|
||||
* No such event, it belongs to another team, or the caller's team did not resolve. One
|
||||
* reason for all three, so the response does not vary with which it was. Unrelated to
|
||||
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
|
||||
* action.
|
||||
*/
|
||||
EVENT_NOT_FOUND,
|
||||
|
||||
/** The action id is not in the vocabulary at all, or has no registered handler. */
|
||||
ACTION_NOT_RECOGNISED,
|
||||
|
||||
/**
|
||||
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
|
||||
* a document whose destination is what failed) cannot be dispatched even by hand.
|
||||
*
|
||||
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
|
||||
* until a kind ships with a restricted action set. Declared now because the guard must
|
||||
* exist before that kind does, not after.
|
||||
*/
|
||||
ACTION_NOT_DECLARED,
|
||||
|
||||
/** The event is already closed, so no further transition is possible. */
|
||||
ALREADY_CLOSED
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public FailureActionException(Reason reason, String message) {
|
||||
this(reason, message, null);
|
||||
}
|
||||
|
||||
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
|
||||
public FailureActionException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
|
||||
* the event is shown and touch nothing else, which is what makes them valid for every kind
|
||||
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
|
||||
*/
|
||||
public enum FailureActionId {
|
||||
ACKNOWLEDGE,
|
||||
DISMISS
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
|
||||
* point: because kinds declare action ids as data, one could name an action nobody implements,
|
||||
* which would otherwise show up as a button that 400s rather than as a failed boot.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FailureActionRegistry {
|
||||
|
||||
private final Map<FailureActionId, FailureAction> byId = new EnumMap<>(FailureActionId.class);
|
||||
|
||||
public FailureActionRegistry(List<FailureAction> actions) {
|
||||
for (FailureAction action : actions) {
|
||||
FailureAction clash = byId.put(action.id(), action);
|
||||
if (clash != null) {
|
||||
throw new IllegalStateException(
|
||||
"Two handlers registered for action "
|
||||
+ action.id()
|
||||
+ ": "
|
||||
+ clash.getClass().getName()
|
||||
+ " and "
|
||||
+ action.getClass().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
|
||||
* first, so one boot tells you everything that is missing.
|
||||
*/
|
||||
@PostConstruct
|
||||
void verifyEveryDeclaredActionHasAHandler() {
|
||||
List<String> gaps =
|
||||
Arrays.stream(FailureKind.values())
|
||||
.flatMap(
|
||||
kind ->
|
||||
kind.getActions().stream()
|
||||
.filter(action -> !byId.containsKey(action))
|
||||
.map(action -> kind.getId() + " -> " + action))
|
||||
.toList();
|
||||
if (!gaps.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Failure kinds declare actions with no registered handler: " + gaps);
|
||||
}
|
||||
log.debug(
|
||||
"Failure action registry initialised with {} handler(s) for {} kind(s)",
|
||||
byId.size(),
|
||||
FailureKind.values().length);
|
||||
}
|
||||
|
||||
public Optional<FailureAction> find(FailureActionId id) {
|
||||
return Optional.ofNullable(byId.get(id));
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Maps a thrown failure onto a {@link FailureKind}. A tool's 4xx arrives as a {@link
|
||||
* RestClientResponseException} whose body is the Problem Details document carrying {@code
|
||||
* errorCode}, so this matches on codes rather than exception messages.
|
||||
*
|
||||
* <p>Anything unrecognised becomes {@link FailureKind#UNKNOWN}, so every failed run still gets a
|
||||
* record.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FailureClassifier {
|
||||
|
||||
/** Set by {@code GlobalExceptionHandler#createProblemDetailResponse}. */
|
||||
private static final String ERROR_CODE_PROPERTY = "errorCode";
|
||||
|
||||
/**
|
||||
* Depth bound on the cause chain. The JDK forbids self-causation but not a longer cycle (A
|
||||
* caused by B caused by A), which an unbounded walk would spin on. Real chains are a handful
|
||||
* deep.
|
||||
*/
|
||||
private static final int MAX_CAUSE_DEPTH = 16;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Refuse to start on an ambiguous registry: two kinds claiming one code would make {@link
|
||||
* #classify} depend on declaration order. Checked here because this is what resolves codes to
|
||||
* kinds, and at boot so the message names the codes rather than arriving as a class-init error.
|
||||
*/
|
||||
@PostConstruct
|
||||
void verifyNoErrorCodeIsClaimedTwice() {
|
||||
List<String> duplicates = FailureKind.duplicateErrorCodes();
|
||||
if (!duplicates.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Error codes claimed by more than one failure kind: " + duplicates);
|
||||
}
|
||||
}
|
||||
|
||||
/** Never null, never throws. A classifier that can fail would lose the failure it describes. */
|
||||
public FailureKind classify(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
FailureKind resolved = classifyOne(current);
|
||||
if (resolved != FailureKind.UNKNOWN) {
|
||||
return resolved;
|
||||
}
|
||||
Throwable cause = current.getCause();
|
||||
current = cause == current ? null : cause;
|
||||
}
|
||||
return FailureKind.UNKNOWN;
|
||||
}
|
||||
|
||||
private FailureKind classifyOne(Throwable throwable) {
|
||||
// A tool step's 4xx/5xx: the Problem Details body names the error code.
|
||||
if (throwable instanceof RestClientResponseException responseException) {
|
||||
String code = errorCodeFromBody(responseException);
|
||||
if (code != null) {
|
||||
return FailureKind.byErrorCode(code).orElse(FailureKind.UNKNOWN);
|
||||
}
|
||||
}
|
||||
// Thrown in-process (not over loopback): the exception carries its own code.
|
||||
if (throwable instanceof ExceptionUtils.ErrorCodeProvider provider) {
|
||||
return FailureKind.byErrorCode(provider.getErrorCode()).orElse(FailureKind.UNKNOWN);
|
||||
}
|
||||
return FailureKind.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull {@code errorCode} from a Problem Details body, or null when the body is absent, not
|
||||
* JSON, or has no such property (an entitlement sentinel has {@code error} instead).
|
||||
*/
|
||||
private String errorCodeFromBody(RestClientResponseException exception) {
|
||||
String body = exception.getResponseBodyAsString();
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode code = root.get(ERROR_CODE_PROPERTY);
|
||||
if (code == null || !code.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String text = code.asString();
|
||||
return text.isBlank() ? null : text;
|
||||
} catch (JacksonException e) {
|
||||
log.debug("Downstream error body was not JSON; classifying as UNKNOWN");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
|
||||
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
|
||||
*
|
||||
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
|
||||
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
|
||||
* failed run a record, and kinds get promoted out of it as production shows what occurs.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
INPUT_PASSWORD_PROTECTED(
|
||||
FailureStage.INPUT,
|
||||
FailureSeverity.ERROR,
|
||||
FailureRemedy.NEEDS_USER_INPUT,
|
||||
FailureScope.FILE,
|
||||
errorCodes("E004"),
|
||||
fallback("This document is password-protected, so the pipeline could not read it."),
|
||||
offer(ACKNOWLEDGE),
|
||||
offer(DISMISS, "dismissSkipFile")),
|
||||
|
||||
UNKNOWN(
|
||||
FailureStage.INTERNAL,
|
||||
FailureSeverity.ERROR,
|
||||
FailureRemedy.PERMANENT,
|
||||
FailureScope.RUN,
|
||||
noErrorCodes(),
|
||||
fallback("This run failed for a reason Stirling does not yet recognise."),
|
||||
offer(ACKNOWLEDGE),
|
||||
offer(DISMISS));
|
||||
|
||||
private static final String KEY_PREFIX = "portal.failures.kind.";
|
||||
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
|
||||
|
||||
/**
|
||||
* Every claimed {@code ErrorCode}, to the kind claiming it. Indexed once rather than scanned
|
||||
* per lookup, so a duplicate cannot be resolved by declaration order without anyone noticing. A
|
||||
* duplicate is refused at boot; see {@link #duplicateErrorCodes()}.
|
||||
*/
|
||||
private static final Map<String, FailureKind> BY_ERROR_CODE = indexErrorCodes();
|
||||
|
||||
private final FailureStage stage;
|
||||
private final FailureSeverity severity;
|
||||
private final FailureRemedy remedy;
|
||||
private final FailureScope scope;
|
||||
|
||||
/** English fallback, used when the client has no translation for {@link #getTitleKey()}. */
|
||||
private final String defaultTitle;
|
||||
|
||||
/** Codes from {@code ExceptionUtils.ErrorCode} that map onto this kind; may be empty. */
|
||||
private final List<String> errorCodes;
|
||||
|
||||
/**
|
||||
* What the review surface may offer, in display order. Never empty. No generated accessor,
|
||||
* because {@link Offer} is private; read via {@link #getActions()} or {@link #labelKeyFor}.
|
||||
*/
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final List<Offer> offers;
|
||||
|
||||
FailureKind(
|
||||
FailureStage stage,
|
||||
FailureSeverity severity,
|
||||
FailureRemedy remedy,
|
||||
FailureScope scope,
|
||||
List<String> errorCodes,
|
||||
String defaultTitle,
|
||||
Offer... offers) {
|
||||
this.stage = stage;
|
||||
this.severity = severity;
|
||||
this.remedy = remedy;
|
||||
this.scope = scope;
|
||||
this.errorCodes = List.copyOf(errorCodes);
|
||||
this.defaultTitle = defaultTitle;
|
||||
this.offers = List.of(offers);
|
||||
}
|
||||
|
||||
/**
|
||||
* One action this kind offers, with the key to label it by. One ordered list rather than ids
|
||||
* plus a parallel map of overrides, which could disagree with each other.
|
||||
*
|
||||
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
|
||||
* label
|
||||
*/
|
||||
private record Offer(FailureActionId id, String labelKeySuffix) {}
|
||||
|
||||
/** An action labelled by this kind's own wording, where the generic label reads badly. */
|
||||
private static Offer offer(FailureActionId id, String labelKeySuffix) {
|
||||
return new Offer(id, labelKeySuffix);
|
||||
}
|
||||
|
||||
/** An action labelled by the shared wording for that action. */
|
||||
private static Offer offer(FailureActionId id) {
|
||||
return new Offer(id, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code ErrorCode}s this kind claims. Java has no named arguments, so these factories
|
||||
* exist to label the two constructor arguments whose types do not already name themselves.
|
||||
*/
|
||||
private static List<String> errorCodes(String... codes) {
|
||||
return List.of(codes);
|
||||
}
|
||||
|
||||
/** Claims no {@code ErrorCode}: reached only through the classifier's fallback. */
|
||||
private static List<String> noErrorCodes() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/** The English text shown when the client has no translation for this kind. */
|
||||
private static String fallback(String englishTitle) {
|
||||
return englishTitle;
|
||||
}
|
||||
|
||||
/** Stable wire id. Never renamed once shipped: persisted rows reference it. */
|
||||
public String getId() {
|
||||
return name();
|
||||
}
|
||||
|
||||
public String getTitleKey() {
|
||||
return KEY_PREFIX + lowerCamelId() + ".title";
|
||||
}
|
||||
|
||||
public String getDescriptionKey() {
|
||||
return KEY_PREFIX + lowerCamelId() + ".description";
|
||||
}
|
||||
|
||||
/** The actions this kind offers, in display order. */
|
||||
public List<FailureActionId> getActions() {
|
||||
return offers.stream().map(Offer::id).toList();
|
||||
}
|
||||
|
||||
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
|
||||
public boolean declares(FailureActionId action) {
|
||||
return offers.stream().anyMatch(offer -> offer.id() == action);
|
||||
}
|
||||
|
||||
/** The label key for {@code action}: this kind's own wording, else the generic one. */
|
||||
public String labelKeyFor(FailureActionId action) {
|
||||
return offers.stream()
|
||||
.filter(offer -> offer.id() == action && offer.labelKeySuffix() != null)
|
||||
.map(offer -> ACTION_KEY_PREFIX + offer.labelKeySuffix())
|
||||
.findFirst()
|
||||
.orElseGet(() -> genericLabelKey(action));
|
||||
}
|
||||
|
||||
/** The label key used when a kind supplies no wording of its own. */
|
||||
static String genericLabelKey(FailureActionId action) {
|
||||
return ACTION_KEY_PREFIX + toLowerCamel(action.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup by wire id. Empty rather than throwing: ids arrive from persisted rows and clients.
|
||||
*/
|
||||
public static Optional<FailureKind> byId(String id) {
|
||||
if (id == null || id.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Arrays.stream(values()).filter(kind -> kind.name().equals(id)).findFirst();
|
||||
}
|
||||
|
||||
private static Map<String, FailureKind> indexErrorCodes() {
|
||||
Map<String, FailureKind> index = new HashMap<>();
|
||||
for (FailureKind kind : values()) {
|
||||
for (String code : kind.errorCodes) {
|
||||
index.putIfAbsent(code, kind);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes claimed by more than one kind, which would make classification depend on declaration
|
||||
* order. Empty in a well-formed registry.
|
||||
*
|
||||
* <p>Reported for a caller to act on rather than thrown from class init, where it would arrive
|
||||
* as an {@code ExceptionInInitializerError} blamed on whatever touched the enum first, then as
|
||||
* {@code NoClassDefFoundError} everywhere after. {@link FailureClassifier} refuses to start.
|
||||
*/
|
||||
static List<String> duplicateErrorCodes() {
|
||||
return duplicatesIn(Arrays.stream(values()).flatMap(kind -> kind.errorCodes.stream()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The codes appearing more than once, first-seen order. Split out from the registry because the
|
||||
* registry is a closed enum: this is the only seam at which the detection itself can be shown
|
||||
* to find anything.
|
||||
*/
|
||||
static List<String> duplicatesIn(Stream<String> codes) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
return codes.filter(code -> !seen.add(code)).distinct().toList();
|
||||
}
|
||||
|
||||
/** The kind claiming {@code errorCode}, if any. Empty for a code no kind has adopted yet. */
|
||||
public static Optional<FailureKind> byErrorCode(String errorCode) {
|
||||
if (errorCode == null || errorCode.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(BY_ERROR_CODE.get(errorCode));
|
||||
}
|
||||
|
||||
private String lowerCamelId() {
|
||||
return toLowerCamel(name());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code INPUT_PASSWORD_PROTECTED} to {@code inputPasswordProtected}, for i18n key building.
|
||||
*/
|
||||
private static String toLowerCamel(String screamingSnake) {
|
||||
String[] parts = screamingSnake.toLowerCase(Locale.ROOT).split("_");
|
||||
StringBuilder out = new StringBuilder(parts[0]);
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
if (parts[i].isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
out.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wire shape of one registry entry, served so a client can describe kinds it was not built with.
|
||||
* Also a feature probe: a build without the proprietary module has no such route, so a 404 means
|
||||
* there is no failure tracking here.
|
||||
*/
|
||||
public record FailureKindView(
|
||||
String id,
|
||||
FailureStage stage,
|
||||
FailureSeverity severity,
|
||||
FailureRemedy remedy,
|
||||
FailureScope scope,
|
||||
List<String> errorCodes,
|
||||
String titleKey,
|
||||
String descriptionKey,
|
||||
String defaultTitle,
|
||||
List<ActionDeclaration> actions) {
|
||||
|
||||
public static FailureKindView of(FailureKind kind) {
|
||||
return new FailureKindView(
|
||||
kind.getId(),
|
||||
kind.getStage(),
|
||||
kind.getSeverity(),
|
||||
kind.getRemedy(),
|
||||
kind.getScope(),
|
||||
kind.getErrorCodes(),
|
||||
kind.getTitleKey(),
|
||||
kind.getDescriptionKey(),
|
||||
kind.getDefaultTitle(),
|
||||
kind.getActions().stream()
|
||||
.map(
|
||||
action ->
|
||||
new ActionDeclaration(
|
||||
action.name(), kind.labelKeyFor(action)))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/** An action this kind offers, with the label key to render it by. */
|
||||
public record ActionDeclaration(String id, String labelKey) {}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* What kind of run failed, not where it was started from. A policy is a policy whether a schedule
|
||||
* fired it or someone clicked Run in the editor, so the two questions are answered separately:
|
||||
* {@code actor} names the person for an attended run and {@code sourceId} names the folder, bucket
|
||||
* or webhook for an unattended one.
|
||||
*/
|
||||
public enum FailureOrigin {
|
||||
|
||||
/** One tool called directly, with no policy around it. Today that means the editor. */
|
||||
TOOL,
|
||||
|
||||
/** The policy engine ran it, however the run was triggered. */
|
||||
POLICY,
|
||||
|
||||
/**
|
||||
* The watched-folder pipeline that predates policies. Declared ahead of its producer: nothing
|
||||
* writes this yet, because that pipeline records no failures at all. Instrumenting it is its
|
||||
* own piece of work.
|
||||
*/
|
||||
PIPELINE
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* What intervention would clear this failure. Advisory metadata for the review surface; nothing
|
||||
* branches on it server-side yet.
|
||||
*/
|
||||
public enum FailureRemedy {
|
||||
TRANSIENT,
|
||||
NEEDS_USER_INPUT,
|
||||
NEEDS_FILE_FIX,
|
||||
NEEDS_CONFIG_FIX,
|
||||
NEEDS_SERVER_FIX,
|
||||
PERMANENT
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* What the failure is about, which is what the dedup key groups repeats by: one file, run, policy,
|
||||
* source, or the whole server.
|
||||
*/
|
||||
public enum FailureScope {
|
||||
FILE,
|
||||
RUN,
|
||||
POLICY,
|
||||
SOURCE,
|
||||
SERVER
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/** How loudly a failure kind should be surfaced. */
|
||||
public enum FailureSeverity {
|
||||
ERROR,
|
||||
WARNING,
|
||||
INFO
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* Where in a run's life the failure happened: reading the document ({@code INPUT}), a tool step or
|
||||
* the engine ({@code INTERNAL}), delivery ({@code OUTPUT}), a gate refusing it ({@code BLOCKED}),
|
||||
* or never admitted ({@code NEVER_RAN}).
|
||||
*
|
||||
* <p>All five declared up front so a later kind needs no enum change, which would strand the value
|
||||
* already snapshotted on existing rows.
|
||||
*/
|
||||
public enum FailureStage {
|
||||
INPUT,
|
||||
INTERNAL,
|
||||
OUTPUT,
|
||||
BLOCKED,
|
||||
NEVER_RAN
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Read model for one recorded incident: the entity's fields with the registry kind already
|
||||
* resolved.
|
||||
*
|
||||
* <p>The facets are the row's own snapshots and can differ from {@code kind}'s current facets on an
|
||||
* old row, which is the point of snapshotting, so read them here rather than from {@code kind}.
|
||||
*/
|
||||
public record FileRunEvent(
|
||||
String id,
|
||||
Long teamId,
|
||||
String actor,
|
||||
FailureKind kind,
|
||||
FailureStage stage,
|
||||
FailureSeverity severity,
|
||||
FailureScope scope,
|
||||
FailureOrigin origin,
|
||||
String policyId,
|
||||
String runId,
|
||||
String fileId,
|
||||
String detail,
|
||||
String dedupKey,
|
||||
int occurrences,
|
||||
FileRunEventStatus status,
|
||||
String statusActor,
|
||||
Instant statusAt,
|
||||
Instant createdAt,
|
||||
Instant lastSeenAt) {
|
||||
|
||||
/**
|
||||
* Project an entity. An unrecognised {@code kindId} (written by a newer build, or since
|
||||
* removed) falls back to {@link FailureKind#UNKNOWN}, so the row stays readable and actionable.
|
||||
*/
|
||||
public static FileRunEvent of(FileRunEventEntity entity) {
|
||||
return new FileRunEvent(
|
||||
entity.getId(),
|
||||
entity.getTeamId(),
|
||||
entity.getActor(),
|
||||
FailureKind.byId(entity.getKindId()).orElse(FailureKind.UNKNOWN),
|
||||
entity.getStage(),
|
||||
entity.getSeverity(),
|
||||
entity.getScope(),
|
||||
entity.getOrigin(),
|
||||
entity.getPolicyId(),
|
||||
entity.getRunId(),
|
||||
entity.getFileId(),
|
||||
entity.getDetail(),
|
||||
entity.getDedupKey(),
|
||||
entity.getOccurrences(),
|
||||
entity.getStatus(),
|
||||
entity.getStatusActor(),
|
||||
entity.getStatusAt(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getLastSeenAt());
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
|
||||
* the team comes from the authenticated principal, never the request.
|
||||
*
|
||||
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
|
||||
* {@link #requireFailureReviewAllowed()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/file-run-events")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "File run events", description = "Recorded policy and pipeline failures")
|
||||
public class FileRunEventController {
|
||||
|
||||
/** Cap on one page, so a client cannot ask for the whole table. */
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
|
||||
private final FileRunEventService service;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List recorded failures",
|
||||
description =
|
||||
"Failures recorded for the caller's team, newest first. Each row carries its"
|
||||
+ " available actions already resolved.")
|
||||
public FileRunEventsResponse list(
|
||||
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
|
||||
@RequestParam(required = false) FileRunEventStatus status,
|
||||
@RequestParam(required = false) String kindId,
|
||||
@RequestParam(required = false) Integer limit) {
|
||||
requireFailureReviewAllowed();
|
||||
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
|
||||
|
||||
List<FileRunEventView> events =
|
||||
// The kind filter is part of the query, before the limit is applied: filtering an
|
||||
// already-limited page could return nothing while matching rows exist.
|
||||
service.list(status, kindId, cappedLimit).stream()
|
||||
.map(event -> FileRunEventView.of(event, service.availableActions(event)))
|
||||
.toList();
|
||||
return new FileRunEventsResponse(events);
|
||||
}
|
||||
|
||||
@PostMapping("/{eventId}/actions/{actionId}")
|
||||
@Operation(
|
||||
summary = "Apply an action to a recorded failure",
|
||||
description =
|
||||
"Rejected with 400 if the failure's kind does not declare the action, so an"
|
||||
+ " action that makes no sense for a given failure cannot be applied.")
|
||||
public FileRunEventView act(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String actionId,
|
||||
@RequestBody(required = false) ActionRequest request) {
|
||||
requireFailureReviewAllowed();
|
||||
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
|
||||
try {
|
||||
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
|
||||
return FileRunEventView.of(updated, service.availableActions(updated));
|
||||
} catch (FailureActionException e) {
|
||||
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/kinds")
|
||||
@Operation(
|
||||
summary = "List known failure kinds",
|
||||
description =
|
||||
"The failure registry. Lets a client describe kinds it was not built with, and"
|
||||
+ " doubles as the probe for whether failure tracking exists at all.")
|
||||
public List<FailureKindView> kinds() {
|
||||
requireFailureReviewAllowed();
|
||||
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
|
||||
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
|
||||
* manages a team's automation: a member can trigger runs, a leader reviews them.
|
||||
*
|
||||
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
|
||||
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
|
||||
*/
|
||||
private void requireFailureReviewAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Recorded failures may only be reviewed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed row is a conflict rather than a bad request: the request was well-formed and would
|
||||
* have been valid a moment earlier.
|
||||
*/
|
||||
private static HttpStatus statusFor(FailureActionException.Reason reason) {
|
||||
return switch (reason) {
|
||||
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
|
||||
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
|
||||
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
|
||||
public record FileRunEventsResponse(List<FileRunEventView> events) {}
|
||||
|
||||
/** Inputs an action declared it needs. Empty for both actions that exist today. */
|
||||
public record ActionRequest(Map<String, String> inputs) {
|
||||
|
||||
Map<String, String> safeInputs() {
|
||||
return inputs == null ? Map.of() : inputs;
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One recorded failure incident. {@code actor} and {@code teamId} are plain values rather than
|
||||
* foreign keys, matching {@link stirling.software.proprietary.policy.source.SourceEntity}, and the
|
||||
* facets are snapshots at write time so re-classifying a kind never rewrites an old row.
|
||||
*
|
||||
* <p>Holds no document name or content: {@code fileId} is opaque, and {@code detail} is stripped of
|
||||
* names by {@link RecordFailure}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "file_run_events",
|
||||
indexes = {
|
||||
// the review surface: this team's rows, newest first, optionally filtered by status
|
||||
@Index(name = "idx_file_run_events_team", columnList = "team_id, status, last_seen_at")
|
||||
},
|
||||
// One row per incident, enforced by the database rather than only by the store's read-then-
|
||||
// insert: two runs failing identically at the same moment would otherwise both find no row
|
||||
// and both insert, leaving a twin that reappears after the first is dismissed. Doubles as
|
||||
// the
|
||||
// rollup's lookup index. Note SQL treats NULLs as distinct, so this does not constrain the
|
||||
// unteamed rows a login-disabled deployment writes.
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_file_run_events_dedup",
|
||||
columnNames = {"team_id", "dedup_key"}))
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class FileRunEventEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private String id;
|
||||
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
/** The user whose work produced the failure. Null when login is disabled. */
|
||||
@Column(name = "actor")
|
||||
private String actor;
|
||||
|
||||
@Column(name = "kind_id", nullable = false)
|
||||
private String kindId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "stage", nullable = false)
|
||||
private FailureStage stage;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "severity", nullable = false)
|
||||
private FailureSeverity severity;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope", nullable = false)
|
||||
private FailureScope scope;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "origin", nullable = false)
|
||||
private FailureOrigin origin;
|
||||
|
||||
@Column(name = "policy_id")
|
||||
private String policyId;
|
||||
|
||||
@Column(name = "run_id")
|
||||
private String runId;
|
||||
|
||||
@Column(name = "file_id")
|
||||
private String fileId;
|
||||
|
||||
@Column(name = "detail", columnDefinition = "text")
|
||||
private String detail;
|
||||
|
||||
@Column(name = "dedup_key", length = 64, nullable = false)
|
||||
private String dedupKey;
|
||||
|
||||
/** How many times this same failure has been seen. Starts at 1; the rollup increments it. */
|
||||
@Column(name = "occurrences", nullable = false)
|
||||
private int occurrences;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private FileRunEventStatus status;
|
||||
|
||||
/** Who last changed {@link #status}. Null while the row is untouched. */
|
||||
@Column(name = "status_actor")
|
||||
private String statusActor;
|
||||
|
||||
@Column(name = "status_at")
|
||||
private Instant statusAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "last_seen_at", nullable = false)
|
||||
private Instant lastSeenAt;
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity, String> {
|
||||
|
||||
/**
|
||||
* This team's events, newest first, scoped in the query rather than loaded and filtered. A
|
||||
* {@code null} teamId matches the rows with no team (login disabled), mirroring {@link
|
||||
* stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain
|
||||
* {@code = null} would return nothing.
|
||||
*/
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)"
|
||||
+ " order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeam(
|
||||
@Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable);
|
||||
|
||||
/** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and e.status = :status"
|
||||
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeamAndStatus(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("status") FileRunEventStatus status,
|
||||
@Param("kindId") String kindId,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* Fold a repeat into an incident in one statement, against the row's <em>current</em> values. A
|
||||
* read-modify-save here would be a merge of a possibly stale snapshot: concurrent folds would
|
||||
* lose counts, and an in-flight fold could overwrite a reviewer's dismiss. Same idiom as {@code
|
||||
* SourceDocCountRepository#increment} and friends.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set e.occurrences = e.occurrences + 1,"
|
||||
+ " e.lastSeenAt = :now, e.detail = coalesce(:detail, e.detail)"
|
||||
+ " where e.id = :id")
|
||||
int fold(@Param("id") String id, @Param("now") Instant now, @Param("detail") String detail);
|
||||
|
||||
/**
|
||||
* Reopen a resolved incident whose failure has recurred. Guarded on the current status so only
|
||||
* {@code RESOLVED} flips; a concurrent dismiss is never overwritten back to {@code NEW}.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set"
|
||||
+ " e.status = stirling.software.proprietary.failure.FileRunEventStatus.NEW,"
|
||||
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status ="
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED")
|
||||
int reopenIfResolved(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* Apply a status transition only if the row is still in one of {@code allowedFrom}. The guard
|
||||
* runs in the database, so two racing closes cannot both succeed: the loser updates zero rows.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set e.status = :target, e.statusActor = :actor,"
|
||||
+ " e.statusAt = :now where e.id = :id and ((:teamId is null and e.teamId is"
|
||||
+ " null) or e.teamId = :teamId) and e.status in :allowedFrom")
|
||||
int applyStatusIf(
|
||||
@Param("id") String id,
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("target") FileRunEventStatus target,
|
||||
@Param("actor") String actor,
|
||||
@Param("now") Instant now,
|
||||
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
|
||||
|
||||
/**
|
||||
* The most recent row for this exact failure, so the rollup can increment an existing incident
|
||||
* instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows.
|
||||
*/
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and e.dedupKey = :dedupKey order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeamAndDedupKey(
|
||||
@Param("teamId") Long teamId, @Param("dedupKey") String dedupKey, Pageable pageable);
|
||||
|
||||
/** One row by id, but only if it belongs to {@code teamId}. */
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where e.id = :id and ((:teamId is null and e.teamId"
|
||||
+ " is null) or e.teamId = :teamId)")
|
||||
Optional<FileRunEventEntity> findByIdAndTeam(
|
||||
@Param("id") String id, @Param("teamId") Long teamId);
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Reads and acts on incidents for the calling user's team.
|
||||
*
|
||||
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
|
||||
* team always comes from the authenticated principal, and scoping applies only when login is
|
||||
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
|
||||
* reads nothing; see {@link #scope()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileRunEventService {
|
||||
|
||||
private final FileRunEventStore store;
|
||||
private final FailureActionRegistry actionRegistry;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
|
||||
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
|
||||
TeamScope scope = scope();
|
||||
if (!scope.permitted()) {
|
||||
return List.of();
|
||||
}
|
||||
return store.list(scope.teamId(), status, kindId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an action against one event.
|
||||
*
|
||||
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
|
||||
* event's kind does not declare the action, or the event is already closed
|
||||
*/
|
||||
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
|
||||
TeamScope scope = scope();
|
||||
if (!scope.permitted()) {
|
||||
// Reported as "no such event", the same as an id from another team, so the response
|
||||
// does
|
||||
// not depend on whether the id happens to exist.
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
|
||||
}
|
||||
FileRunEvent event =
|
||||
store.find(eventId, scope.teamId())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND,
|
||||
"No such event: " + eventId));
|
||||
|
||||
FailureActionId resolvedId = parseActionId(actionId);
|
||||
|
||||
// A kind that does not offer an action cannot have it applied, so an action that makes no
|
||||
// sense for a failure is unreachable rather than merely unrendered.
|
||||
if (!event.kind().declares(resolvedId)) {
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.ACTION_NOT_DECLARED,
|
||||
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
|
||||
}
|
||||
if (event.status().terminal()) {
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.ALREADY_CLOSED,
|
||||
"Event " + eventId + " is already " + event.status());
|
||||
}
|
||||
|
||||
FailureAction action =
|
||||
actionRegistry
|
||||
.find(resolvedId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new FailureActionException(
|
||||
FailureActionException.Reason.ACTION_NOT_RECOGNISED,
|
||||
"No handler for action " + resolvedId));
|
||||
|
||||
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of an event's declared actions are usable right now. Decided per row, so the client
|
||||
* never renders a button that would be refused.
|
||||
*/
|
||||
public List<AvailableAction> availableActions(FileRunEvent event) {
|
||||
boolean closed = event.status().terminal();
|
||||
return event.kind().getActions().stream()
|
||||
.map(
|
||||
action ->
|
||||
new AvailableAction(
|
||||
action,
|
||||
event.kind().labelKeyFor(action),
|
||||
!closed,
|
||||
closed ? "portal.failures.disabled.closed" : null))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private FailureActionId parseActionId(String actionId) {
|
||||
for (FailureActionId candidate : FailureActionId.values()) {
|
||||
if (candidate.name().equals(actionId)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.ACTION_NOT_RECOGNISED, "Unknown action: " + actionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows the caller may touch, since a null team id means two different things. Login
|
||||
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
|
||||
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
|
||||
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
|
||||
*/
|
||||
private TeamScope scope() {
|
||||
if (!enforced()) {
|
||||
return TeamScope.of(null);
|
||||
}
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
|
||||
*/
|
||||
private record TeamScope(boolean permitted, Long teamId) {
|
||||
|
||||
static TeamScope of(Long teamId) {
|
||||
return new TeamScope(true, teamId);
|
||||
}
|
||||
|
||||
static TeamScope denied() {
|
||||
return new TeamScope(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentActor() {
|
||||
return enforced() ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
private boolean enforced() {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
/** One action as offered for a specific event, with its resolved availability. */
|
||||
public record AvailableAction(
|
||||
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Disposition of one recorded failure. {@code RESOLVED} is declared but not set yet (it becomes
|
||||
* system-set later); the rollup already defines what a repeat means for it, which is to reopen.
|
||||
*/
|
||||
public enum FileRunEventStatus {
|
||||
NEW(false),
|
||||
ACKNOWLEDGED(false),
|
||||
DISMISSED(true),
|
||||
RESOLVED(true);
|
||||
|
||||
/** The statuses a review queue shows by default: everything still needing a decision. */
|
||||
private static final List<FileRunEventStatus> OPEN =
|
||||
Arrays.stream(values()).filter(status -> !status.terminal).toList();
|
||||
|
||||
/** Whether no further transition is possible: the row is closed. */
|
||||
private final boolean terminal;
|
||||
|
||||
FileRunEventStatus(boolean terminal) {
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
public boolean terminal() {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
public static List<FileRunEventStatus> open() {
|
||||
return OPEN;
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Durable store for recorded incidents. Who may act lives in {@link FileRunEventService}.
|
||||
*
|
||||
* <p>{@link #record} folds a repeat into the existing incident rather than inserting again, keyed
|
||||
* on {@code (teamId, dedupKey)}. That matters for a stateless {@code snapshot} source, which
|
||||
* re-lists every file on each sweep: the same broken file is one incident, not one per sweep.
|
||||
* Distinct files keep distinct rows, so a reviewer can still act on any one of them.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileRunEventStore {
|
||||
|
||||
/** Only ever need the newest match; the index is ordered so this is a single-row read. */
|
||||
private static final Pageable NEWEST = PageRequest.of(0, 1);
|
||||
|
||||
private final FileRunEventRepository repository;
|
||||
|
||||
/**
|
||||
* Record a failure, folding it into an existing incident when one matches.
|
||||
*
|
||||
* <p>Deliberately not {@code @Transactional}: each repository call runs in its own transaction,
|
||||
* so losing the insert race below leaves no rolled-back transaction to recover from.
|
||||
*/
|
||||
public FileRunEvent record(RecordFailure command) {
|
||||
String dedupKey = command.dedupKey();
|
||||
Instant now = Instant.now();
|
||||
|
||||
Optional<FileRunEvent> folded =
|
||||
findByDedupKey(command.teamId(), dedupKey)
|
||||
.flatMap(row -> absorb(row.getId(), command, now));
|
||||
if (folded.isPresent()) {
|
||||
return folded.get();
|
||||
}
|
||||
|
||||
try {
|
||||
return FileRunEvent.of(insert(command, dedupKey, now));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// The unique constraint fired, so a concurrent writer inserted this incident between
|
||||
// our read and our insert. Their row is the incident; fold into it instead of failing.
|
||||
return findByDedupKey(command.teamId(), dedupKey)
|
||||
.flatMap(row -> absorb(row.getId(), command, now))
|
||||
.orElseThrow(() -> e);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<FileRunEventEntity> findByDedupKey(Long teamId, String dedupKey) {
|
||||
return repository.findByTeamAndDedupKey(teamId, dedupKey, NEWEST).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a repeat into an existing incident. {@code DISMISSED} stays dismissed, so a reviewer's
|
||||
* "stop showing me this" holds; {@code RESOLVED} reopens, so a recurrence is visible again.
|
||||
*
|
||||
* <p>Both steps are single guarded UPDATE statements against the row's current values, never a
|
||||
* save of the entity we read: that would merge a possibly stale snapshot over concurrent
|
||||
* writes, losing counts and reverting a dismiss that landed in between.
|
||||
*
|
||||
* <p>Empty when the row went away between the caller's read and the fold, so the caller inserts
|
||||
* instead. Reporting that as an error would lose the incident for a log line.
|
||||
*/
|
||||
private Optional<FileRunEvent> absorb(String id, RecordFailure command, Instant now) {
|
||||
if (repository.fold(id, now, command.detail()) == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
repository.reopenIfResolved(id);
|
||||
return repository.findById(id).map(FileRunEvent::of);
|
||||
}
|
||||
|
||||
private FileRunEventEntity insert(RecordFailure command, String dedupKey, Instant now) {
|
||||
FailureKind kind = command.kind();
|
||||
FileRunEventEntity entity = new FileRunEventEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setTeamId(command.teamId());
|
||||
entity.setActor(command.actor());
|
||||
entity.setKindId(kind.getId());
|
||||
// Snapshot the facets so a later registry edit does not rewrite what this row meant.
|
||||
entity.setStage(kind.getStage());
|
||||
entity.setSeverity(kind.getSeverity());
|
||||
entity.setScope(kind.getScope());
|
||||
entity.setOrigin(command.origin());
|
||||
entity.setPolicyId(command.policyId());
|
||||
entity.setRunId(command.runId());
|
||||
entity.setFileId(command.fileId());
|
||||
entity.setDetail(command.detail());
|
||||
entity.setDedupKey(dedupKey);
|
||||
entity.setOccurrences(1);
|
||||
entity.setStatus(FileRunEventStatus.NEW);
|
||||
entity.setCreatedAt(now);
|
||||
entity.setLastSeenAt(now);
|
||||
// Flushed so a duplicate-key violation surfaces here, inside record()'s catch. A plain
|
||||
// save() defers the INSERT to commit time once a caller is transactional, and the
|
||||
// violation would escape the catch as a 500.
|
||||
return repository.saveAndFlush(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of incidents, newest first, optionally narrowed to one status and one kind. The kind
|
||||
* filter lives in the query, before the limit: filtering a already-limited page could return
|
||||
* nothing while matching rows exist.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<FileRunEvent> list(
|
||||
Long teamId, FileRunEventStatus status, String kindId, int limit) {
|
||||
Pageable page = PageRequest.of(0, Math.max(1, limit));
|
||||
List<FileRunEventEntity> rows =
|
||||
status == null
|
||||
? repository.findByTeam(teamId, kindId, page)
|
||||
: repository.findByTeamAndStatus(teamId, status, kindId, page);
|
||||
return rows.stream().map(FileRunEvent::of).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<FileRunEvent> find(String id, Long teamId) {
|
||||
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition an open row, refusing a closed one. The service checks {@code terminal()} before
|
||||
* dispatching, but that check and this write are separate requests under concurrency; the
|
||||
* guarded UPDATE is what makes two racing closes resolve to one winner.
|
||||
*
|
||||
* @throws FailureActionException {@code ALREADY_CLOSED} when the row exists but is terminal,
|
||||
* {@code EVENT_NOT_FOUND} when it does not exist for this team
|
||||
*/
|
||||
@Transactional
|
||||
public FileRunEvent applyStatus(
|
||||
String id, Long teamId, FileRunEventStatus target, String actor) {
|
||||
return applyStatus(id, teamId, target, actor, FileRunEventStatus.open())
|
||||
.orElseThrow(() -> refusalFor(id, teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #applyStatus(String, Long, FileRunEventStatus, String)} but with the caller naming
|
||||
* which current statuses may transition. Empty when the row exists outside {@code allowedFrom}.
|
||||
*
|
||||
* <p>The UPDATE runs first and nothing is read beforehand: a pre-read only to classify the
|
||||
* refusal would be one more thing to race with, and would report a row deleted in between as
|
||||
* closed rather than missing.
|
||||
*/
|
||||
@Transactional
|
||||
public Optional<FileRunEvent> applyStatus(
|
||||
String id,
|
||||
Long teamId,
|
||||
FileRunEventStatus target,
|
||||
String actor,
|
||||
Collection<FileRunEventStatus> allowedFrom) {
|
||||
if (repository.applyStatusIf(id, teamId, target, actor, Instant.now(), allowedFrom) == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition, or accept that someone else already reached {@code target}. The loser of a race
|
||||
* reads the winner's row back rather than re-stamping it, so the first actor keeps the credit.
|
||||
*/
|
||||
@Transactional
|
||||
public FileRunEvent applyStatusOnce(
|
||||
String id,
|
||||
Long teamId,
|
||||
FileRunEventStatus target,
|
||||
String actor,
|
||||
Collection<FileRunEventStatus> allowedFrom) {
|
||||
return applyStatus(id, teamId, target, actor, allowedFrom)
|
||||
.or(() -> find(id, teamId).filter(current -> current.status() == target))
|
||||
.orElseThrow(() -> refusalFor(id, teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told
|
||||
* apart after the fact rather than before, so the answer describes the row the UPDATE saw.
|
||||
*/
|
||||
private FailureActionException refusalFor(String id, Long teamId) {
|
||||
return repository.findByIdAndTeam(id, teamId).isPresent()
|
||||
? new FailureActionException(
|
||||
FailureActionException.Reason.ALREADY_CLOSED,
|
||||
"Event " + id + " is already closed")
|
||||
// The same reason the service raises for an id it never found, so losing a delete
|
||||
// race answers 404 like every other "no such event" rather than 400.
|
||||
: new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + id);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wire shape of one incident. Carries i18n keys plus {@code defaultTitle} rather than rendered
|
||||
* copy, so the server can ship a new kind without a client release, and {@code actions} arrive
|
||||
* already resolved so the client needs no rules. No document name: {@code fileId} is an opaque
|
||||
* reference.
|
||||
*/
|
||||
public record FileRunEventView(
|
||||
String id,
|
||||
String kindId,
|
||||
FailureStage stage,
|
||||
FailureSeverity severity,
|
||||
FailureScope scope,
|
||||
FailureOrigin origin,
|
||||
FailureRemedy remedy,
|
||||
String titleKey,
|
||||
String descriptionKey,
|
||||
String defaultTitle,
|
||||
String detail,
|
||||
String policyId,
|
||||
String runId,
|
||||
String fileId,
|
||||
String actor,
|
||||
int occurrences,
|
||||
FileRunEventStatus status,
|
||||
String statusActor,
|
||||
List<ActionView> actions,
|
||||
long createdAt,
|
||||
long lastSeenAt) {
|
||||
|
||||
public static FileRunEventView of(
|
||||
FileRunEvent event, List<FileRunEventService.AvailableAction> actions) {
|
||||
FailureKind kind = event.kind();
|
||||
return new FileRunEventView(
|
||||
event.id(),
|
||||
kind.getId(),
|
||||
event.stage(),
|
||||
event.severity(),
|
||||
event.scope(),
|
||||
event.origin(),
|
||||
kind.getRemedy(),
|
||||
kind.getTitleKey(),
|
||||
kind.getDescriptionKey(),
|
||||
kind.getDefaultTitle(),
|
||||
event.detail(),
|
||||
event.policyId(),
|
||||
event.runId(),
|
||||
event.fileId(),
|
||||
event.actor(),
|
||||
event.occurrences(),
|
||||
event.status(),
|
||||
event.statusActor(),
|
||||
actions.stream().map(ActionView::of).toList(),
|
||||
event.createdAt() == null ? 0L : event.createdAt().toEpochMilli(),
|
||||
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
|
||||
}
|
||||
|
||||
/** One button, as offered for this specific row. */
|
||||
public record ActionView(
|
||||
String id, String labelKey, boolean enabled, String disabledReasonKey) {
|
||||
|
||||
static ActionView of(FileRunEventService.AvailableAction action) {
|
||||
return new ActionView(
|
||||
action.id().name(),
|
||||
action.labelKey(),
|
||||
action.enabled(),
|
||||
action.disabledReasonKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* The seam the policy engine calls when a run fails: classify, then record. Best-effort throughout,
|
||||
* so failing to write a row cannot change the failure the caller already observed.
|
||||
*
|
||||
* <p>The team comes from the originating policy rather than the calling thread, which carries only
|
||||
* an audit principal. An ad-hoc run has no stored policy, so its rows land unteamed.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PolicyFailureRecorder {
|
||||
|
||||
private final FailureClassifier classifier;
|
||||
private final FileRunEventStore store;
|
||||
private final PolicyStore policyStore;
|
||||
|
||||
/**
|
||||
* Record a failed run, classifying {@code cause}. {@code detail} is the message the run itself
|
||||
* reported, kept verbatim so an {@link FailureKind#UNKNOWN} row is still diagnosable.
|
||||
*/
|
||||
public void recordRunFailure(
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String fileIdentity,
|
||||
String detail,
|
||||
Throwable cause) {
|
||||
record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failure whose kind is already decided, for paths with no exception to classify (a
|
||||
* run rejected at admission). Named distinctly rather than overloading {@link
|
||||
* #recordRunFailure}, whose argument list is otherwise near-identical, so a null cause cannot
|
||||
* pick the wrong one.
|
||||
*/
|
||||
public void recordRunFailureAs(
|
||||
FailureKind kind, String runId, String policyId, String actor, String detail) {
|
||||
record(kind, runId, policyId, actor, null, detail);
|
||||
}
|
||||
|
||||
private void record(
|
||||
FailureKind kind,
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String fileIdentity,
|
||||
String detail) {
|
||||
try {
|
||||
store.record(
|
||||
RecordFailure.forRun(
|
||||
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
|
||||
} catch (RuntimeException e) {
|
||||
// Deliberately swallowed: see the class comment.
|
||||
log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Long teamFor(String policyId) {
|
||||
if (policyId == null || policyId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return policyStore.get(policyId).map(policy -> policy.teamId()).orElse(null);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Could not resolve team for policy {}: {}", policyId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* Everything needed to record one failure. Every reference field is nullable, because a failure can
|
||||
* happen before a file or policy is known and with no user at all (a trigger-fired run on a
|
||||
* login-disabled deployment). Only {@code kind} and {@code origin} are required.
|
||||
*/
|
||||
public record RecordFailure(
|
||||
FailureKind kind,
|
||||
FailureOrigin origin,
|
||||
Long teamId,
|
||||
String actor,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String detail) {
|
||||
|
||||
/** Upper bound on a stored message, so one enormous stack trace cannot fill the column. */
|
||||
private static final int MAX_DETAIL_LENGTH = 2_000;
|
||||
|
||||
public RecordFailure {
|
||||
if (kind == null) {
|
||||
throw new IllegalArgumentException("kind is required");
|
||||
}
|
||||
if (origin == null) {
|
||||
throw new IllegalArgumentException("origin is required");
|
||||
}
|
||||
// Capped here rather than at each call site, since this record is the only way a row is
|
||||
// written, and an unclassified failure carries a raw message of unbounded length. Stored
|
||||
// verbatim otherwise: it is the user's own error about their own file, and hiding parts of
|
||||
// it makes the row harder to act on without making it meaningfully safer.
|
||||
detail = truncate(detail);
|
||||
}
|
||||
|
||||
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
|
||||
public static RecordFailure forRun(
|
||||
FailureKind kind,
|
||||
Long teamId,
|
||||
String actor,
|
||||
String policyId,
|
||||
String runId,
|
||||
String fileId,
|
||||
String detail) {
|
||||
return new RecordFailure(
|
||||
kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this failure is about, per the row's scope. Two failures sharing a kind and a scope
|
||||
* reference are the same incident; see {@link #dedupKey()}.
|
||||
*/
|
||||
public String scopeRef() {
|
||||
return switch (kind.getScope()) {
|
||||
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
|
||||
case RUN -> nullToEmpty(runId);
|
||||
case POLICY -> nullToEmpty(policyId);
|
||||
case SOURCE -> nullToEmpty(sourceId);
|
||||
// One server-wide condition is one incident regardless of which run tripped over it.
|
||||
case SERVER -> "";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The file this failure is about, or the run when the producer could not name one. Without the
|
||||
* fallback, every file failing the same way under one policy shares a scope reference, and the
|
||||
* second onwards folds into the first as though one document had failed repeatedly.
|
||||
*
|
||||
* <p>Prefixed so a file id and a run id cannot collide.
|
||||
*/
|
||||
private String fileOrRun() {
|
||||
return isBlank(fileId) ? "run:" + nullToEmpty(runId) : "file:" + fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether this failure repeats an existing incident: SHA-256 of {@code
|
||||
* kindId|scope|scopeRef}. Hashed so a long scope reference (an S3 key) fits a fixed-width
|
||||
* index, and deterministic so two nodes converge on one incident. Every input is an enum name
|
||||
* or an id.
|
||||
*/
|
||||
public String dedupKey() {
|
||||
String raw = kind.getId() + "|" + kind.getScope().name() + "|" + scopeRef();
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 is mandated by the JDK; unreachable outside a broken runtime.
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(String detail) {
|
||||
if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
|
||||
return detail;
|
||||
}
|
||||
// Leave room for the ellipsis so the cap is the cap, and step back once more rather than
|
||||
// cutting between the halves of a surrogate pair, which would store invalid UTF-16.
|
||||
int end = MAX_DETAIL_LENGTH - 1;
|
||||
if (Character.isHighSurrogate(detail.charAt(end - 1))) {
|
||||
end--;
|
||||
}
|
||||
return detail.substring(0, end) + "…";
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+44
-3
@@ -32,6 +32,8 @@ import stirling.software.common.service.ResourceMonitor;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
import stirling.software.common.util.JobContext;
|
||||
import stirling.software.proprietary.failure.FailureKind;
|
||||
import stirling.software.proprietary.failure.PolicyFailureRecorder;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
@@ -72,6 +74,8 @@ public class PolicyEngine {
|
||||
private final PolicyExecutor stepExecutor;
|
||||
private final TaskManager taskManager;
|
||||
private final PolicyRunRegistry registry;
|
||||
// Durable record of why a run failed. Best-effort by contract: see PolicyFailureRecorder.
|
||||
private final PolicyFailureRecorder failureRecorder;
|
||||
private final FileStorage fileStorage;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final List<PolicyOutputSink> outputSinks;
|
||||
@@ -123,12 +127,26 @@ public class PolicyEngine {
|
||||
// async
|
||||
// worker.
|
||||
String principal = currentActingPrincipal();
|
||||
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
|
||||
return submitForPrincipal(
|
||||
principal, principal, policyId, definition, inputs, null, listener);
|
||||
}
|
||||
|
||||
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return runPolicy(policy, inputs, null, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, with the source's opaque
|
||||
* reference to the document being run. Carried so a failure can say which document it was
|
||||
* about, and so the same document failing again folds into one incident.
|
||||
*/
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy,
|
||||
PolicyInputs inputs,
|
||||
String fileIdentity,
|
||||
PolicyProgressListener listener) {
|
||||
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
|
||||
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
|
||||
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
|
||||
@@ -145,7 +163,7 @@ public class PolicyEngine {
|
||||
new PipelineDefinition(
|
||||
policy.name(), policy.steps(), outputResolver.resolve(policy));
|
||||
return submitForPrincipal(
|
||||
policy.owner(), fileOwner, policy.id(), definition, inputs, listener);
|
||||
policy.owner(), fileOwner, policy.id(), definition, inputs, fileIdentity, listener);
|
||||
}
|
||||
|
||||
private PolicyRunHandle submitForPrincipal(
|
||||
@@ -154,6 +172,7 @@ public class PolicyEngine {
|
||||
String policyId,
|
||||
PipelineDefinition definition,
|
||||
PolicyInputs inputs,
|
||||
String fileIdentity,
|
||||
PolicyProgressListener listener) {
|
||||
// Scope the run id to the current user (this request thread) so the file-download
|
||||
// ownership check passes. No-op when security is off.
|
||||
@@ -163,7 +182,7 @@ public class PolicyEngine {
|
||||
if (policyId != null) {
|
||||
taskManager.putMetadata(runId, "policyId", policyId);
|
||||
}
|
||||
PolicyRun run = new PolicyRun(runId, policyId, definition);
|
||||
PolicyRun run = new PolicyRun(runId, policyId, definition, fileIdentity);
|
||||
registry.register(run);
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
||||
@@ -271,6 +290,7 @@ public class PolicyEngine {
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
recordFailure(run, message, e);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured
|
||||
// entitlement
|
||||
@@ -294,17 +314,20 @@ public class PolicyEngine {
|
||||
run.failWithCode(
|
||||
message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
recordFailure(run, message, e);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
recordFailure(run, message, e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
recordFailure(run, message, e);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
@@ -322,11 +345,29 @@ public class PolicyEngine {
|
||||
// Transient admission rejection, not a processing failure (see QUEUE_FULL_CODE).
|
||||
run.failWithCode(message, QUEUE_FULL_CODE, null);
|
||||
taskManager.setError(run.getRunId(), message);
|
||||
// No exception to classify here: nothing was thrown by a tool, the run simply was not
|
||||
// admitted. Record it explicitly so a run lost to load pressure is still accounted for.
|
||||
failureRecorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), null, message);
|
||||
completion.complete(run);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record why a run failed. Called after the run's own state transition and task-manager update,
|
||||
* so a recording problem cannot change the outcome the caller observes.
|
||||
*/
|
||||
private void recordFailure(PolicyRun run, String message, Throwable cause) {
|
||||
failureRecorder.recordRunFailure(
|
||||
run.getRunId(),
|
||||
run.getPolicyId(),
|
||||
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
|
||||
run.getFileIdentity(),
|
||||
message,
|
||||
cause);
|
||||
}
|
||||
|
||||
private WaitState suspend(PolicyInputRequiredException e) {
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
for (Resource resource : e.getPendingFiles()) {
|
||||
|
||||
+18
-3
@@ -281,18 +281,33 @@ public class PolicyExecutor {
|
||||
}
|
||||
for (Resource file : files) {
|
||||
if (!matchesType(file, accepted)) {
|
||||
// Reports the extension rather than the filename, since this message becomes the
|
||||
// run's error and is persisted on the failure record.
|
||||
throw new IOException(
|
||||
"Step "
|
||||
+ operation
|
||||
+ " accepts "
|
||||
+ accepted
|
||||
+ " but received '"
|
||||
+ file.getFilename()
|
||||
+ "'");
|
||||
+ " but received a '"
|
||||
+ extensionOf(file)
|
||||
+ "' file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The file's extension, or {@code unknown} when it has no usable name. */
|
||||
private static String extensionOf(Resource file) {
|
||||
String filename = file.getFilename();
|
||||
if (filename == null) {
|
||||
return "unknown";
|
||||
}
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0 || dot == filename.length() - 1) {
|
||||
return "unknown";
|
||||
}
|
||||
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean matchesType(Resource file, List<String> acceptedExtensions) {
|
||||
String filename = file.getFilename();
|
||||
if (filename == null) {
|
||||
|
||||
+5
-4
@@ -79,7 +79,7 @@ public class PolicyRunner {
|
||||
// Generator pipeline: one run with no input. Still fall through to the cleanup
|
||||
// below so rows recorded for its folder outputs are pruned like anything else,
|
||||
// instead of accumulating until the policy is deleted.
|
||||
runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
|
||||
runIds.add(startRun(policy, PolicyInputs.of(List.of()), null, unused -> {}));
|
||||
}
|
||||
for (PipelineInput input : inputs) {
|
||||
String sourceId = input.sourceId();
|
||||
@@ -167,17 +167,18 @@ public class PolicyRunner {
|
||||
List<String> runIds = new ArrayList<>();
|
||||
long docsFed = 0;
|
||||
for (ResolvedInput unit : work) {
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.fileIdentity(), unit.onComplete()));
|
||||
docsFed += unit.inputs().primary().size();
|
||||
}
|
||||
docCounter.record(sourceId, docsFed);
|
||||
return runIds;
|
||||
}
|
||||
|
||||
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
private String startRun(
|
||||
Policy policy, PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
|
||||
log.info("Running policy {} ({})", policy.id(), policy.name());
|
||||
PolicyRunHandle handle =
|
||||
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
|
||||
policyEngine.runPolicy(policy, inputs, fileIdentity, PolicyProgressListener.NOOP);
|
||||
handle.completion()
|
||||
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
|
||||
return handle.runId();
|
||||
|
||||
+2
-1
@@ -118,8 +118,9 @@ public class FolderInputSource implements InputSource {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
ResolvedInput.forFile(
|
||||
PolicyInputs.of(List.of(fileResource(file))),
|
||||
identity,
|
||||
success ->
|
||||
completeConsumed(
|
||||
ctx, identity, file, gate, contentHash, success)));
|
||||
|
||||
+22
-3
@@ -2,21 +2,40 @@ package stirling.software.proprietary.policy.input;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import stirling.software.proprietary.policy.ledger.IdentityHasher;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
|
||||
/**
|
||||
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
|
||||
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
|
||||
* several of these, one per file.
|
||||
*
|
||||
* @param fileIdentity stable opaque reference to the document, or null when the source has none.
|
||||
* Always hashed, never the source's own identity: a folder identity is a path, and a path is a
|
||||
* filename. See {@link #forFile}.
|
||||
*/
|
||||
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
public record ResolvedInput(
|
||||
PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
|
||||
|
||||
public ResolvedInput {
|
||||
onComplete = onComplete == null ? success -> {} : onComplete;
|
||||
}
|
||||
|
||||
/** No completion side effect. */
|
||||
/**
|
||||
* One document, referenced by the hash of the source's identity for it. Stable across sweeps,
|
||||
* which is what lets the same broken file fold into one incident instead of opening a fresh one
|
||||
* every time the source re-lists it.
|
||||
*/
|
||||
public static ResolvedInput forFile(
|
||||
PolicyInputs inputs, String identity, Consumer<Boolean> onComplete) {
|
||||
return new ResolvedInput(
|
||||
inputs,
|
||||
identity == null ? null : IdentityHasher.identityHash(identity),
|
||||
onComplete);
|
||||
}
|
||||
|
||||
/** No document reference and no completion side effect. */
|
||||
public static ResolvedInput of(PolicyInputs inputs) {
|
||||
return new ResolvedInput(inputs, success -> {});
|
||||
return new ResolvedInput(inputs, null, success -> {});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -119,8 +119,9 @@ public class S3InputSource implements InputSource {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
ResolvedInput.forFile(
|
||||
PolicyInputs.of(List.of(objectResource(client, config, object))),
|
||||
identity,
|
||||
success ->
|
||||
completeConsumed(
|
||||
ctx,
|
||||
|
||||
+2
-1
@@ -100,8 +100,9 @@ public class WebhookInputSource implements InputSource {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
ResolvedInput.forFile(
|
||||
PolicyInputs.of(List.of(fileResource(file))),
|
||||
identity,
|
||||
success -> completeConsumed(ctx, identity, file, gate, success)));
|
||||
}
|
||||
return work;
|
||||
|
||||
+10
-1
@@ -22,6 +22,13 @@ public class PolicyRun {
|
||||
private final String policyId;
|
||||
|
||||
private final PipelineDefinition definition;
|
||||
|
||||
/**
|
||||
* The source's opaque reference to the document this run is about; null for an ad-hoc run or a
|
||||
* source that names no document. Hashed upstream, so never a path or a filename.
|
||||
*/
|
||||
private final String fileIdentity;
|
||||
|
||||
private final Instant createdAt = Instant.now();
|
||||
|
||||
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
|
||||
@@ -49,10 +56,12 @@ public class PolicyRun {
|
||||
private volatile List<ResultFile> outputs = List.of();
|
||||
private volatile Instant updatedAt = Instant.now();
|
||||
|
||||
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
|
||||
public PolicyRun(
|
||||
String runId, String policyId, PipelineDefinition definition, String fileIdentity) {
|
||||
this.runId = runId;
|
||||
this.policyId = policyId;
|
||||
this.definition = definition;
|
||||
this.fileIdentity = fileIdentity;
|
||||
}
|
||||
|
||||
public int stepCount() {
|
||||
|
||||
+2
-1
@@ -103,8 +103,9 @@ public class NetworkInputSource implements InputSource {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
ResolvedInput.forFile(
|
||||
PolicyInputs.of(List.of(resource(config, file))),
|
||||
identity,
|
||||
success ->
|
||||
completeConsumed(ctx, config, file, identity, gate, success)));
|
||||
}
|
||||
|
||||
+4
-2
@@ -38,7 +38,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.failure"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -51,7 +52,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model"
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.failure"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Tests for {@link FailureClassifier}. The fixtures matter as much as the assertions: a tool's 400
|
||||
* arrives as a {@link org.springframework.web.client.RestClientResponseException} carrying the
|
||||
* Problem Details body, so these build that exact shape rather than a convenient stand-in.
|
||||
*/
|
||||
class FailureClassifierTest {
|
||||
|
||||
private final FailureClassifier classifier =
|
||||
new FailureClassifier(JsonMapper.builder().build());
|
||||
|
||||
/** The Problem Details body {@code GlobalExceptionHandler} produces for a coded exception. */
|
||||
private static HttpClientErrorException problemDetail(HttpStatus status, String errorCode) {
|
||||
String body =
|
||||
"""
|
||||
{"type":"/errors/pdf-password","title":"PDF password required",\
|
||||
"status":%d,"detail":"The PDF Document is passworded",\
|
||||
"errorCode":"%s","timestamp":"2026-01-01T00:00:00Z"}"""
|
||||
.formatted(status.value(), errorCode);
|
||||
return HttpClientErrorException.create(
|
||||
status,
|
||||
status.getReasonPhrase(),
|
||||
new HttpHeaders(),
|
||||
body.getBytes(StandardCharsets.UTF_8),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static HttpClientErrorException rawBody(String body) {
|
||||
return HttpClientErrorException.create(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Bad Request",
|
||||
new HttpHeaders(),
|
||||
body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("a downstream tool error")
|
||||
class DownstreamErrors {
|
||||
|
||||
@Test
|
||||
void withAClaimedErrorCodeClassifiesToThatKind() {
|
||||
assertThat(classifier.classify(problemDetail(HttpStatus.BAD_REQUEST, "E004")))
|
||||
.isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withAnUnclaimedErrorCodeFallsBackToUnknown() {
|
||||
// E001 (corrupted PDF) is a real code that no kind has adopted yet. It must land in
|
||||
// UNKNOWN rather than being force-fitted to the nearest kind.
|
||||
assertThat(classifier.classify(problemDetail(HttpStatus.BAD_REQUEST, "E001")))
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withANonJsonBodyFallsBackToUnknownWithoutThrowing() {
|
||||
assertThat(classifier.classify(rawBody("Internal Server Error")))
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withAnEmptyBodyFallsBackToUnknown() {
|
||||
assertThat(classifier.classify(rawBody(""))).isEqualTo(FailureKind.UNKNOWN);
|
||||
assertThat(classifier.classify(rawBody(null))).isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withJsonButNoErrorCodePropertyFallsBackToUnknown() {
|
||||
assertThat(classifier.classify(rawBody("{\"detail\":\"nope\"}")))
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void entitlementSentinelIsNotMistakenForACodedFailure() {
|
||||
// An entitlement 402 carries `error`, not `errorCode`. It must not be misclassified,
|
||||
// and specifically must not be attributed to a file problem.
|
||||
FailureKind kind =
|
||||
classifier.classify(
|
||||
rawBody("{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":false}"));
|
||||
assertThat(kind).isEqualTo(FailureKind.UNKNOWN);
|
||||
assertThat(kind).isNotEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("an exception thrown in process")
|
||||
class InProcess {
|
||||
|
||||
@Test
|
||||
void carryingItsOwnErrorCodeIsClassifiedByThatCode() {
|
||||
// Not every path goes over loopback; a coded exception thrown directly must work too.
|
||||
ExceptionUtils.PdfPasswordException e =
|
||||
ExceptionUtils.createPdfPasswordException(new IOException("password"));
|
||||
assertThat(classifier.classify(e)).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClassifiedWhenWrappedInsideAnotherException() {
|
||||
// The engine wraps and rethrows, so the code is often not on the outermost throwable.
|
||||
Throwable wrapped =
|
||||
new IllegalStateException(
|
||||
"step failed",
|
||||
ExceptionUtils.createPdfPasswordException(new IOException("pw")));
|
||||
assertThat(classifier.classify(wrapped))
|
||||
.isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("anything unrecognised")
|
||||
class Fallback {
|
||||
|
||||
@Test
|
||||
void toolTimeoutIsUnknownUntilAKindClaimsIt() {
|
||||
// Deliberate: a timeout has its own catch site in the engine but no kind yet. This
|
||||
// proves the fallback holds even where specific handling already exists.
|
||||
InternalApiTimeoutException e =
|
||||
new InternalApiTimeoutException(
|
||||
"/api/v1/misc/ocr-pdf", Duration.ofSeconds(30), new IOException("t"));
|
||||
assertThat(classifier.classify(e)).isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareRuntimeExceptionIsUnknown() {
|
||||
assertThat(classifier.classify(new RuntimeException("boom")))
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullMessageDoesNotBreakClassification() {
|
||||
assertThat(classifier.classify(new RuntimeException((String) null)))
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullThrowableIsUnknown() {
|
||||
assertThat(classifier.classify(null)).isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclicCauseChainTerminates() {
|
||||
// The JDK blocks self-causation but not a two-node cycle, and an unbounded walk would
|
||||
// spin on it forever, taking the run's error handling down with it.
|
||||
RuntimeException outer = new RuntimeException("outer");
|
||||
RuntimeException inner = new RuntimeException("inner", outer);
|
||||
outer.initCause(inner);
|
||||
|
||||
assertThat(classifier.classify(outer)).isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCodeBuriedBeyondTheDepthBoundIsNotFound() {
|
||||
// Documents the trade: the bound is what guarantees termination, so a pathologically
|
||||
// deep chain classifies as UNKNOWN rather than hanging. UNKNOWN is always recoverable.
|
||||
Throwable deepest =
|
||||
ExceptionUtils.createPdfPasswordException(new IOException("password"));
|
||||
Throwable chain = deepest;
|
||||
for (int i = 0; i < 20; i++) {
|
||||
chain = new IllegalStateException("wrap " + i, chain);
|
||||
}
|
||||
assertThat(classifier.classify(chain)).isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the boot guard on an ambiguous registry")
|
||||
class BootGuard {
|
||||
|
||||
@Test
|
||||
void isWiredToRunAtStartupRatherThanOnlyBeingCallable() throws Exception {
|
||||
// The check moved out of FailureKind's class initialiser to get a readable message.
|
||||
// That only buys anything if Spring actually invokes it.
|
||||
assertThat(
|
||||
FailureClassifier.class
|
||||
.getDeclaredMethod("verifyNoErrorCodeIsClaimedTwice")
|
||||
.isAnnotationPresent(PostConstruct.class))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTheRealRegistry() {
|
||||
assertThatCode(classifier::verifyNoErrorCodeIsClaimedTwice).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wouldRejectADuplicate() {
|
||||
// FailureKind is a closed enum, so the guard cannot be handed a bad registry. What can
|
||||
// be shown is that the detection it depends on finds a duplicate when there is one.
|
||||
assertThat(FailureKind.duplicatesIn(Stream.of("E004", "E001", "E004")))
|
||||
.containsExactly("E004");
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link FailureKind}. Mostly invariants over the whole enum rather than assertions about
|
||||
* individual members, so a kind added later cannot be malformed in a way that only shows up as a
|
||||
* button that fails at runtime.
|
||||
*/
|
||||
class FailureKindTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("every kind is well formed")
|
||||
class Invariants {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void hasAllFacetsAndAtLeastOneAction(FailureKind kind) {
|
||||
assertThat(kind.getStage()).isNotNull();
|
||||
assertThat(kind.getSeverity()).isNotNull();
|
||||
assertThat(kind.getRemedy()).isNotNull();
|
||||
assertThat(kind.getScope()).isNotNull();
|
||||
assertThat(kind.getActions())
|
||||
.as("a kind with no actions cannot be triaged at all")
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void hasCopyKeysAndAnEnglishFallback(FailureKind kind) {
|
||||
assertThat(kind.getTitleKey()).isNotBlank();
|
||||
assertThat(kind.getDescriptionKey()).isNotBlank();
|
||||
// The fallback is what lets a client render a kind it was never built with.
|
||||
assertThat(kind.getDefaultTitle()).isNotBlank();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void idIsScreamingSnakeCase(FailureKind kind) {
|
||||
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
|
||||
}
|
||||
|
||||
@Test
|
||||
void idsAreUnique() {
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (FailureKind kind : FailureKind.values()) {
|
||||
assertThat(ids.add(kind.getId())).as("duplicate id %s", kind.getId()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyDeclaredErrorCodeIsARealErrorCode(FailureKind kind) {
|
||||
Set<String> known =
|
||||
Arrays.stream(ExceptionUtils.ErrorCode.values())
|
||||
.map(ExceptionUtils.ErrorCode::getCode)
|
||||
.collect(Collectors.toSet());
|
||||
assertThat(known).containsAll(kind.getErrorCodes());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyDeclaredActionResolvesToALabelKey(FailureKind kind) {
|
||||
// Offers are one ordered list, so a label with no matching action is unrepresentable;
|
||||
// all that is left to assert is that each action gets a key.
|
||||
for (FailureActionId action : kind.getActions()) {
|
||||
assertThat(kind.labelKeyFor(action)).startsWith("portal.failures.action.");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTwoKindsClaimTheSameErrorCode() {
|
||||
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
|
||||
// guard reads that method, so a version of it that always returned empty would leave
|
||||
// the guard decorative and every other test still passing.
|
||||
assertThat(FailureKind.duplicateErrorCodes()).isEmpty();
|
||||
|
||||
Set<String> claimed = new HashSet<>();
|
||||
Stream.of(FailureKind.values())
|
||||
.flatMap(kind -> kind.getErrorCodes().stream())
|
||||
.forEach(
|
||||
code ->
|
||||
assertThat(claimed.add(code))
|
||||
.as("error code %s claimed twice", code)
|
||||
.isTrue());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("every derived key resolves to English copy")
|
||||
class Copy {
|
||||
|
||||
/**
|
||||
* The enum builds its i18n keys from the constant name, so renaming a kind or shipping a
|
||||
* new one sends keys the client has no copy for, and the UI renders the raw key. Nothing
|
||||
* else checks that: the portal reads these keys at runtime, and the unused-translation
|
||||
* audit only looks the other way, for copy no source file mentions.
|
||||
*/
|
||||
private static final Set<String> KEYS = englishKeys();
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void titleAndDescriptionAreTranslated(FailureKind kind) {
|
||||
assertThat(KEYS).contains(kind.getTitleKey(), kind.getDescriptionKey());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyOfferedActionIsLabelled(FailureKind kind) {
|
||||
for (FailureActionId action : kind.getActions()) {
|
||||
assertThat(KEYS)
|
||||
.as("%s offers %s, but nothing labels it", kind.getId(), action)
|
||||
.contains(kind.labelKeyFor(action));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every dotted key in the English file, as {@code [section]} plus the name before {@code
|
||||
* =}.
|
||||
*/
|
||||
private static Set<String> englishKeys() {
|
||||
Set<String> keys = new HashSet<>();
|
||||
String section = "";
|
||||
for (String raw : readTranslations()) {
|
||||
String line = raw.strip();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
section = line.substring(1, line.length() - 1).strip() + ".";
|
||||
continue;
|
||||
}
|
||||
int equals = line.indexOf('=');
|
||||
if (equals > 0) {
|
||||
keys.add(section + line.substring(0, equals).strip());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static List<String> readTranslations() {
|
||||
// Located by walking up, so the test does not depend on the directory Gradle runs it
|
||||
// in.
|
||||
Path relative = Path.of("frontend/editor/public/locales/en-US/translation.toml");
|
||||
for (Path dir = Path.of("").toAbsolutePath(); dir != null; dir = dir.getParent()) {
|
||||
Path candidate = dir.resolve(relative);
|
||||
if (Files.isRegularFile(candidate)) {
|
||||
try {
|
||||
return Files.readAllLines(candidate, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No " + relative + " above " + Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("UNKNOWN is the catch-all")
|
||||
class Unknown {
|
||||
|
||||
@Test
|
||||
void existsAndCanBeTriaged() {
|
||||
assertThat(FailureKind.UNKNOWN.getActions())
|
||||
.containsExactlyInAnyOrder(
|
||||
FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimsNoErrorCodeSoItNeverWinsALookup() {
|
||||
assertThat(FailureKind.UNKNOWN.getErrorCodes()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("lookup")
|
||||
class Lookup {
|
||||
|
||||
@Test
|
||||
void byIdRoundTripsEveryKind() {
|
||||
for (FailureKind kind : FailureKind.values()) {
|
||||
assertThat(FailureKind.byId(kind.getId())).contains(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void byIdIsEmptyForAnUnknownId() {
|
||||
// Ids arrive from persisted rows and from clients, so this must not throw.
|
||||
assertThat(FailureKind.byId("NO_SUCH_KIND")).isEmpty();
|
||||
assertThat(FailureKind.byId(null)).isEmpty();
|
||||
assertThat(FailureKind.byId(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void byErrorCodeResolvesTheClaimingKind() {
|
||||
assertThat(FailureKind.byErrorCode("E004"))
|
||||
.contains(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void byErrorCodeIsEmptyForACodeNoKindHasAdoptedYet() {
|
||||
// E001 is PDF_CORRUPTED: a real error code, deliberately not yet a kind.
|
||||
assertThat(FailureKind.byErrorCode("E001")).isEmpty();
|
||||
assertThat(FailureKind.byErrorCode(null)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("action declaration and labels")
|
||||
class Actions {
|
||||
|
||||
@Test
|
||||
void declaresOnlyWhatItLists() {
|
||||
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.ACKNOWLEDGE)).isTrue();
|
||||
assertThat(FailureKind.UNKNOWN.declares(FailureActionId.DISMISS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overriddenLabelWinsOverTheGenericOne() {
|
||||
String label =
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DISMISS);
|
||||
assertThat(label).isEqualTo("portal.failures.action.dismissSkipFile");
|
||||
assertThat(label).isNotEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericLabelIsUsedWhenAKindDeclaresNoOverride() {
|
||||
assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS))
|
||||
.isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS))
|
||||
.isEqualTo("portal.failures.action.dismiss");
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyKeysAreDerivedFromTheIdInLowerCamel() {
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getTitleKey())
|
||||
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getDescriptionKey())
|
||||
.isEqualTo("portal.failures.kind.inputPasswordProtected.description");
|
||||
}
|
||||
}
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Tests for {@link FileRunEventController}: the wire shape, the status mapping for each refusal
|
||||
* reason, and that the team is taken from the principal rather than the request.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileRunEventControllerTest {
|
||||
|
||||
private static final Long TEAM = 4L;
|
||||
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private FileRunEventStore store;
|
||||
private FileRunEventController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
FailureActionRegistry registry =
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store)));
|
||||
controller =
|
||||
new FileRunEventController(
|
||||
new FileRunEventService(store, registry, authority, userService, props),
|
||||
authority,
|
||||
props);
|
||||
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(true);
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn("reviewer@example.com");
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
|
||||
return store.record(
|
||||
new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"author@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
fileId,
|
||||
"the raw failure message"));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("listing")
|
||||
class Listing {
|
||||
|
||||
@Test
|
||||
void returnsOnlyTheCallersTeamsRows() {
|
||||
given(FailureKind.UNKNOWN, TEAM, "mine");
|
||||
given(FailureKind.UNKNOWN, 99L, "theirs");
|
||||
|
||||
assertThat(controller.list(null, null, null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("mine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheCopyKeysAndTheEnglishFallback() {
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEventView view = controller.list(null, null, null).events().getFirst();
|
||||
|
||||
assertThat(view.titleKey())
|
||||
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
|
||||
assertThat(view.descriptionKey())
|
||||
.isEqualTo("portal.failures.kind.inputPasswordProtected.description");
|
||||
assertThat(view.defaultTitle()).isNotBlank();
|
||||
assertThat(view.detail()).isEqualTo("the raw failure message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesActionsAlreadyResolvedForTheRow() {
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
List<FileRunEventView.ActionView> actions =
|
||||
controller.list(null, null, null).events().getFirst().actions();
|
||||
|
||||
assertThat(actions)
|
||||
.extracting(FileRunEventView.ActionView::id)
|
||||
.containsExactlyInAnyOrder("ACKNOWLEDGE", "DISMISS");
|
||||
assertThat(actions).allMatch(FileRunEventView.ActionView::enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
List<FileRunEventView.ActionView> actions =
|
||||
controller
|
||||
.list(FileRunEventStatus.DISMISSED, null, null)
|
||||
.events()
|
||||
.getFirst()
|
||||
.actions();
|
||||
|
||||
assertThat(actions).noneMatch(FileRunEventView.ActionView::enabled);
|
||||
assertThat(actions)
|
||||
.allMatch(a -> "portal.failures.disabled.closed".equals(a.disabledReasonKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByStatusAndByKind() {
|
||||
FileRunEvent open = given(FailureKind.UNKNOWN, TEAM, "open");
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
|
||||
controller.act(open.id(), "ACKNOWLEDGE", null);
|
||||
|
||||
assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events())
|
||||
.hasSize(1);
|
||||
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("locked");
|
||||
assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void capsTheRequestedLimitSoAClientCannotAskForTheWholeTable() {
|
||||
// Distinct files, so five genuinely distinct rows exist. The earlier version of this
|
||||
// test used a RUN-scoped kind with one hardcoded run id, so all five folded into one
|
||||
// row and the assertion measured the rollup rather than the cap.
|
||||
for (int i = 0; i < 5; i++) {
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f" + i);
|
||||
}
|
||||
|
||||
// Over-large and non-positive limits are both coerced rather than rejected.
|
||||
assertThat(controller.list(null, null, 100_000).events()).hasSize(5);
|
||||
assertThat(controller.list(null, null, 2).events()).hasSize(2);
|
||||
assertThat(controller.list(null, null, 0).events()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void kindFilterAppliesBeforeTheLimitNotAfter() {
|
||||
// A post-limit filter would take the newest N rows and then discard non-matches,
|
||||
// returning nothing while matching rows exist. The filter lives in the query.
|
||||
given(FailureKind.UNKNOWN, TEAM, "old-unknown");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i);
|
||||
}
|
||||
|
||||
assertThat(controller.list(null, "UNKNOWN", 1).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("old-unknown");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("acting")
|
||||
class Acting {
|
||||
|
||||
@Test
|
||||
void appliesADeclaredActionAndReturnsTheUpdatedRow() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null);
|
||||
|
||||
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(updated.statusActor()).isEqualTo("reviewer@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAnAbsentBodyBecauseTheseActionsNeedNoInput() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(controller.act(event.id(), "DISMISS", null).status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownActionIsABadRequest() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "APPROVE", null)))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherTeamsRowIsNotFoundRatherThanForbidden() {
|
||||
// 404 rather than 403, so the response does not confirm the row exists.
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThat(statusOf(() -> controller.act(theirs.id(), "ACKNOWLEDGE", null)))
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAlreadyClosedRowIsAConflict() {
|
||||
// The request was well formed and would have been valid a moment earlier.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
|
||||
.isEqualTo(HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
private HttpStatus statusOf(Runnable call) {
|
||||
try {
|
||||
call.run();
|
||||
} catch (ResponseStatusException e) {
|
||||
return HttpStatus.valueOf(e.getStatusCode().value());
|
||||
}
|
||||
throw new AssertionError("expected the call to be refused");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the kinds catalogue")
|
||||
class Kinds {
|
||||
|
||||
@Test
|
||||
void servesEveryRegisteredKindWithItsFacetsAndDeclaredActions() {
|
||||
List<FailureKindView> kinds = controller.kinds();
|
||||
|
||||
assertThat(kinds).hasSameSizeAs(FailureKind.values());
|
||||
assertThat(kinds).extracting(FailureKindView::id).contains("UNKNOWN");
|
||||
assertThat(kinds).allMatch(k -> k.stage() != null && !k.actions().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheLabelKeyForEachDeclaredAction() {
|
||||
FailureKindView locked =
|
||||
controller.kinds().stream()
|
||||
.filter(k -> "INPUT_PASSWORD_PROTECTED".equals(k.id()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(locked.actions())
|
||||
.extracting(FailureKindView.ActionDeclaration::labelKey)
|
||||
.contains("portal.failures.action.dismissSkipFile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheErrorCodesEachKindClaims() {
|
||||
FailureKindView locked =
|
||||
controller.kinds().stream()
|
||||
.filter(k -> "INPUT_PASSWORD_PROTECTED".equals(k.id()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(locked.errorCodes()).containsExactly("E004");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("only a team leader may review failures")
|
||||
class Authorization {
|
||||
|
||||
@Test
|
||||
void aMemberCannotListThem() {
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.list(null, null, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberCannotDispatchAnAction() {
|
||||
// The read being refused is not enough on its own: an id learned any other way must
|
||||
// not let a member close another user's failure.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1");
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRegistryIsAlsoLeaderOnly() {
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.kinds())
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDisabledTrustsTheLocalOperator() {
|
||||
// A single-user deployment has no roles to distinguish, so the role gate must not lock
|
||||
// the only user out of their own failures.
|
||||
ApplicationProperties unsecured = new ApplicationProperties();
|
||||
unsecured.getSecurity().setEnableLogin(false);
|
||||
FileRunEventController noLogin =
|
||||
new FileRunEventController(
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(
|
||||
new AcknowledgeAction(store),
|
||||
new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
unsecured),
|
||||
authority,
|
||||
unsecured);
|
||||
|
||||
assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException();
|
||||
// Not merely permitted: the role is never consulted at all, which is what makes the
|
||||
// carve-out independent of however the authority answers with no users configured.
|
||||
verify(authority, never()).canEditPolicies();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("team scoping cannot be influenced by the caller")
|
||||
class Scoping {
|
||||
|
||||
@Test
|
||||
void thereIsNoWayToAskForAnotherTeam() {
|
||||
// Stated as a test because the absence of the parameter is the property. Adding a
|
||||
// teamId parameter to list() breaks this at compile time.
|
||||
given(FailureKind.UNKNOWN, 99L, "theirs");
|
||||
|
||||
FileRunEventController.FileRunEventsResponse response =
|
||||
controller.list(null, null, null);
|
||||
|
||||
assertThat(response.events()).isEmpty();
|
||||
assertThat(
|
||||
Arrays.stream(FileRunEventController.class.getDeclaredMethods())
|
||||
.filter(m -> "list".equals(m.getName()))
|
||||
.flatMap(m -> Arrays.stream(m.getParameterTypes()))
|
||||
.toList())
|
||||
.doesNotContain(Long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDisabledReadsTheUnteamedRows() {
|
||||
ApplicationProperties unsecured = new ApplicationProperties();
|
||||
unsecured.getSecurity().setEnableLogin(false);
|
||||
FileRunEventController noLogin =
|
||||
new FileRunEventController(
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(
|
||||
new AcknowledgeAction(store),
|
||||
new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
unsecured),
|
||||
authority,
|
||||
unsecured);
|
||||
given(FailureKind.UNKNOWN, null, "unteamed");
|
||||
given(FailureKind.UNKNOWN, TEAM, "teamed");
|
||||
|
||||
assertThat(noLogin.list(null, null, null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("unteamed");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("action request body")
|
||||
class RequestBody {
|
||||
|
||||
@Test
|
||||
void nullInputsAreTreatedAsEmpty() {
|
||||
assertThat(new FileRunEventController.ActionRequest(null).safeInputs()).isEmpty();
|
||||
assertThat(
|
||||
new FileRunEventController.ActionRequest(Map.of("password", "x"))
|
||||
.safeInputs())
|
||||
.containsEntry("password", "x");
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* End-to-end test of the read path and action dispatch over real HTTP, against live Jetty on a
|
||||
* random port.
|
||||
*
|
||||
* <p>The other tests here call controller methods directly, which skips response serialisation,
|
||||
* body binding, query coercion and status mapping. A double-encoded request body already slipped
|
||||
* through that gap once, so these use a real {@link HttpClient} against a real socket.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = FileRunEventHttpIntegrationTest.TestApp.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class FileRunEventHttpIntegrationTest {
|
||||
|
||||
private static final Long TEAM = 42L;
|
||||
private static final String ACTOR = "reviewer@example.com";
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final JsonMapper mapper = JsonMapper.builder().build();
|
||||
|
||||
/** Shared so a test can seed a row and then read it back over the wire. */
|
||||
private static InMemoryFileRunEventRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void resetRows() {
|
||||
repository.rows.clear();
|
||||
}
|
||||
|
||||
private String seed(FailureKind kind, Long teamId, String fileId, String detail) {
|
||||
FileRunEventStore store = new FileRunEventStore(repository);
|
||||
return store.record(
|
||||
new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"author@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
fileId,
|
||||
detail))
|
||||
.id();
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
return http.send(
|
||||
HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String jsonBody) throws Exception {
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
|
||||
.header("Content-Type", "application/json");
|
||||
builder =
|
||||
jsonBody == null
|
||||
? builder.POST(HttpRequest.BodyPublishers.noBody())
|
||||
: builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the read path")
|
||||
class ReadPath {
|
||||
|
||||
@Test
|
||||
void serialisesAnEventWithItsFacetsCopyKeysAndResolvedActions() throws Exception {
|
||||
seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "acme-msa", "locked tight");
|
||||
|
||||
HttpResponse<String> response = get("/api/v1/file-run-events");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode row = mapper.readTree(response.body()).get("events").get(0);
|
||||
|
||||
assertThat(row.get("kindId").asString()).isEqualTo("INPUT_PASSWORD_PROTECTED");
|
||||
assertThat(row.get("stage").asString()).isEqualTo("INPUT");
|
||||
assertThat(row.get("severity").asString()).isEqualTo("ERROR");
|
||||
assertThat(row.get("scope").asString()).isEqualTo("FILE");
|
||||
assertThat(row.get("origin").asString()).isEqualTo("POLICY");
|
||||
assertThat(row.get("remedy").asString()).isEqualTo("NEEDS_USER_INPUT");
|
||||
assertThat(row.get("titleKey").asString())
|
||||
.isEqualTo("portal.failures.kind.inputPasswordProtected.title");
|
||||
assertThat(row.get("defaultTitle").asString()).isNotBlank();
|
||||
assertThat(row.get("detail").asString()).isEqualTo("locked tight");
|
||||
assertThat(row.get("status").asString()).isEqualTo("NEW");
|
||||
assertThat(row.get("occurrences").asInt()).isEqualTo(1);
|
||||
// Epoch millis, not an ISO string: the client renders relative times from a number.
|
||||
assertThat(row.get("lastSeenAt").isNumber()).isTrue();
|
||||
|
||||
JsonNode actions = row.get("actions");
|
||||
assertThat(actions).hasSize(2);
|
||||
assertThat(actions.get(0).get("id").asString()).isEqualTo("ACKNOWLEDGE");
|
||||
assertThat(actions.get(0).get("labelKey").asString())
|
||||
.isEqualTo("portal.failures.action.acknowledge");
|
||||
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
|
||||
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotReturnAnotherTeamsRows() throws Exception {
|
||||
seed(FailureKind.UNKNOWN, 999L, "theirs", "not yours");
|
||||
|
||||
JsonNode events = mapper.readTree(get("/api/v1/file-run-events").body()).get("events");
|
||||
|
||||
assertThat(events).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresAClientSuppliedTeamId() throws Exception {
|
||||
// The team comes from the principal, so a teamId query param is ignored rather than
|
||||
// honoured.
|
||||
seed(FailureKind.UNKNOWN, 999L, "theirs", "not yours");
|
||||
|
||||
JsonNode events =
|
||||
mapper.readTree(get("/api/v1/file-run-events?teamId=999").body()).get("events");
|
||||
|
||||
assertThat(events).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void coercesQueryParametersAndFiltersOnThem() throws Exception {
|
||||
String open = seed(FailureKind.UNKNOWN, TEAM, "open", "a");
|
||||
seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
|
||||
post("/api/v1/file-run-events/" + open + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
|
||||
|
||||
JsonNode acknowledged =
|
||||
mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body())
|
||||
.get("events");
|
||||
assertThat(acknowledged).hasSize(1);
|
||||
|
||||
JsonNode byKind =
|
||||
mapper.readTree(
|
||||
get("/api/v1/file-run-events?kindId=INPUT_PASSWORD_PROTECTED")
|
||||
.body())
|
||||
.get("events");
|
||||
assertThat(byKind).hasSize(1);
|
||||
assertThat(byKind.get(0).get("fileId").asString()).isEqualTo("locked");
|
||||
|
||||
// `limit` binds as an Integer, so a non-numeric value is a bad request, not a 500.
|
||||
assertThat(get("/api/v1/file-run-events?limit=abc").statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownStatusFilter() throws Exception {
|
||||
assertThat(get("/api/v1/file-run-events?status=BANANA").statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheKindsCatalogue() throws Exception {
|
||||
HttpResponse<String> response = get("/api/v1/file-run-events/kinds");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode kinds = mapper.readTree(response.body());
|
||||
assertThat(kinds).hasSize(FailureKind.values().length);
|
||||
assertThat(kinds.get(0).get("actions").isArray()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("action dispatch")
|
||||
class Dispatch {
|
||||
|
||||
@Test
|
||||
void bindsTheRequestBodyAndReturnsTheUpdatedRow() throws Exception {
|
||||
// The regression guard: an object body, sent as real JSON over the wire, binding into
|
||||
// ActionRequest. A double-encoded string would fail here.
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
|
||||
"{\"inputs\":{}}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode row = mapper.readTree(response.body());
|
||||
assertThat(row.get("status").asString()).isEqualTo("ACKNOWLEDGED");
|
||||
assertThat(row.get("statusActor").asString()).isEqualTo(ACTOR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAPopulatedInputsMap() throws Exception {
|
||||
// Nothing consumes inputs yet, but the shape must bind so the first action that needs
|
||||
// one (a password) does not discover a broken contract.
|
||||
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "locked");
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
|
||||
"{\"inputs\":{\"password\":\"hunter2\"}}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAnAbsentBody() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
|
||||
assertThat(
|
||||
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", null)
|
||||
.statusCode())
|
||||
.isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnUnknownActionToBadRequest() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/APPROVE",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnotherTeamsRowToNotFound() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, 999L, "theirs", "boom");
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/"
|
||||
+ id
|
||||
+ "/actions/ACKNOWLEDGE",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnAlreadyClosedRowToConflict() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}");
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/"
|
||||
+ id
|
||||
+ "/actions/ACKNOWLEDGE",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(409);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDismissedRowComesBackWithItsActionsDisabled() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
|
||||
JsonNode row =
|
||||
mapper.readTree(
|
||||
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", null)
|
||||
.body());
|
||||
|
||||
assertThat(row.get("status").asString()).isEqualTo("DISMISSED");
|
||||
for (JsonNode action : row.get("actions")) {
|
||||
assertThat(action.get("enabled").asBoolean()).isFalse();
|
||||
assertThat(action.get("disabledReasonKey").asString())
|
||||
.isEqualTo("portal.failures.disabled.closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Security is excluded rather than configured to permit everything: the auto-configured
|
||||
* chain would answer 401 before the handler runs, and this test is about routing, body binding,
|
||||
* serialisation and status mapping. Authorisation and team scoping have their own tests.
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(
|
||||
exclude = {
|
||||
SecurityAutoConfiguration.class,
|
||||
ServletWebSecurityAutoConfiguration.class,
|
||||
ManagementWebSecurityAutoConfiguration.class,
|
||||
SecurityFilterAutoConfiguration.class,
|
||||
UserDetailsServiceAutoConfiguration.class
|
||||
})
|
||||
static class TestApp {
|
||||
|
||||
/**
|
||||
* Backed by the shared in-memory repository, since this test is about the HTTP layer and
|
||||
* persistence has its own tests.
|
||||
*/
|
||||
@Bean
|
||||
FileRunEventStore fileRunEventStore() {
|
||||
repository = new InMemoryFileRunEventRepository();
|
||||
return new FileRunEventStore(repository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
FailureActionRegistry failureActionRegistry(List<FailureAction> actions) {
|
||||
return new FailureActionRegistry(actions);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AcknowledgeAction acknowledgeAction(FileRunEventStore store) {
|
||||
return new AcknowledgeAction(store);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DismissAction dismissAction(FileRunEventStore store) {
|
||||
return new DismissAction(store);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
props.getAutomaticallyGenerated().setAppVersion("test");
|
||||
return props;
|
||||
}
|
||||
|
||||
@Bean
|
||||
PolicyManagementAuthority policyManagementAuthority() {
|
||||
PolicyManagementAuthority authority =
|
||||
org.mockito.Mockito.mock(PolicyManagementAuthority.class);
|
||||
org.mockito.Mockito.when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
org.mockito.Mockito.when(authority.canEditPolicies()).thenReturn(true);
|
||||
return authority;
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserServiceInterface userService() {
|
||||
UserServiceInterface users = org.mockito.Mockito.mock(UserServiceInterface.class);
|
||||
org.mockito.Mockito.when(users.getCurrentUsername()).thenReturn(ACTOR);
|
||||
return users;
|
||||
}
|
||||
|
||||
@Bean
|
||||
FileRunEventService fileRunEventService(
|
||||
FileRunEventStore store,
|
||||
FailureActionRegistry registry,
|
||||
PolicyManagementAuthority authority,
|
||||
UserServiceInterface users,
|
||||
ApplicationProperties props) {
|
||||
return new FileRunEventService(store, registry, authority, users, props);
|
||||
}
|
||||
|
||||
@Bean
|
||||
FileRunEventController fileRunEventController(
|
||||
FileRunEventService service,
|
||||
PolicyManagementAuthority authority,
|
||||
ApplicationProperties props) {
|
||||
return new FileRunEventController(service, authority, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Tests for {@link FileRunEventService}: team scoping, the declaration guard, transition legality,
|
||||
* and that triaging an incident leaves the document alone.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileRunEventServiceTest {
|
||||
|
||||
private static final Long TEAM = 3L;
|
||||
private static final String ACTOR = "reviewer@example.com";
|
||||
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private FileRunEventStore store;
|
||||
private FileRunEventService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
|
||||
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
FailureActionRegistry registry =
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store)));
|
||||
registry.verifyEveryDeclaredActionHasAHandler();
|
||||
|
||||
service = new FileRunEventService(store, registry, authority, userService, props);
|
||||
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
|
||||
return store.record(
|
||||
new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"author@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
fileId,
|
||||
"detail"));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("acknowledge")
|
||||
class Acknowledge {
|
||||
|
||||
@Test
|
||||
void movesANewEventToAcknowledgedAndStampsTheActor() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
|
||||
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(updated.statusActor()).isEqualTo(ACTOR);
|
||||
assertThat(updated.statusAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
Instant originalAt = first.statusAt();
|
||||
|
||||
when(userService.getCurrentUsername()).thenReturn("someone-else@example.com");
|
||||
FileRunEvent second = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
|
||||
assertThat(second.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(second.statusActor()).isEqualTo(ACTOR);
|
||||
assertThat(second.statusAt()).isEqualTo(originalAt);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("dismiss")
|
||||
class Dismiss {
|
||||
|
||||
@Test
|
||||
void closesANewEvent() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesAnAcknowledgedEvent() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
|
||||
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("triage never touches the document")
|
||||
class NeverTouchesTheDocument {
|
||||
|
||||
/**
|
||||
* What makes both actions valid for every kind, UNKNOWN included: they are incident
|
||||
* dispositions, not document ones. Asserted structurally — an action whose only dependency
|
||||
* is the event store <em>cannot</em> reach the ledger, file storage, or an output sink. A
|
||||
* mocked-collaborator version of this test passed vacuously, because nothing ever handed
|
||||
* the mocks to the actions.
|
||||
*/
|
||||
@Test
|
||||
void actionsDependOnNothingButTheEventStore() {
|
||||
for (FailureAction action :
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store))) {
|
||||
String name = action.getClass().getSimpleName();
|
||||
List<Class<?>> dependencies =
|
||||
Arrays.stream(action.getClass().getDeclaredFields())
|
||||
// Coverage instrumentation adds its own field; only ours count.
|
||||
.filter(field -> !field.isSynthetic())
|
||||
.map(Field::getType)
|
||||
.toList();
|
||||
|
||||
// Asserted first, so an action that lost its fields fails here rather than
|
||||
// satisfying the check below by holding nothing at all.
|
||||
assertThat(dependencies).as("%s declares no dependencies", name).isNotEmpty();
|
||||
assertThat(dependencies)
|
||||
.as(
|
||||
"%s: an incident disposition reaches the event store and nothing"
|
||||
+ " else. Adding to this is how one starts touching"
|
||||
+ " documents.",
|
||||
name)
|
||||
.containsOnly(FileRunEventStore.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dismissLeavesTheFileReferenceIntactRatherThanClearingIt() {
|
||||
// Nothing is deleted, so the row must still say which file it was about.
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEvent dismissed = service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThat(dismissed.fileId()).isEqualTo("f1");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("guards")
|
||||
class Guards {
|
||||
|
||||
@Test
|
||||
void anotherTeamsEventIsNotFound() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(theirs.id(), "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownEventIdIsNotFound() {
|
||||
assertThatThrownBy(() -> service.dispatch("nope", "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownActionIdIsRejected() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "APPROVE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ACTION_NOT_RECOGNISED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeclaredActionWithNoRegisteredHandlerIsRejectedWithoutAnyStateChange() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
FileRunEventService missingHandler =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(List.of(new AcknowledgeAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
|
||||
assertThatThrownBy(() -> missingHandler.dispatch(event.id(), "DISMISS", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ACTION_NOT_RECOGNISED);
|
||||
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aClosedEventCannotBeActedOnAgain() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "DISMISS", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("available actions are resolved per row")
|
||||
class Availability {
|
||||
|
||||
@Test
|
||||
void openRowOffersEveryDeclaredActionEnabled() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
List<FileRunEventService.AvailableAction> actions = service.availableActions(event);
|
||||
|
||||
assertThat(actions).hasSize(2);
|
||||
assertThat(actions).allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
assertThat(actions).allMatch(action -> action.disabledReasonKey() == null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closedRowOffersThemDisabledWithAReasonRatherThanHidingThem() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
FileRunEvent dismissed = service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
List<FileRunEventService.AvailableAction> actions = service.availableActions(dismissed);
|
||||
|
||||
assertThat(actions).isNotEmpty();
|
||||
assertThat(actions).noneMatch(FileRunEventService.AvailableAction::enabled);
|
||||
assertThat(actions)
|
||||
.allMatch(
|
||||
action ->
|
||||
"portal.failures.disabled.closed"
|
||||
.equals(action.disabledReasonKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheKindsOverriddenLabelWhereItHasOne() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::labelKey)
|
||||
.contains("portal.failures.action.dismissSkipFile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheGenericLabelWhereTheKindDeclaresNoOverride() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::labelKey)
|
||||
.containsExactlyInAnyOrder(
|
||||
"portal.failures.action.acknowledge", "portal.failures.action.dismiss");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("team scoping")
|
||||
class Scoping {
|
||||
|
||||
@Test
|
||||
void listReturnsOnlyTheCallersTeam() {
|
||||
given(FailureKind.UNKNOWN, TEAM, "mine");
|
||||
given(FailureKind.UNKNOWN, 99L, "theirs");
|
||||
|
||||
assertThat(service.list(null, null, 50))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("mine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCallerWhoseTeamCannotBeResolvedReadsNothing() {
|
||||
// A run with no stored policy is recorded unteamed, and those rows are shared by every
|
||||
// team, so a caller whose team does not resolve reads nothing instead.
|
||||
given(FailureKind.UNKNOWN, null, "someone-elses-adhoc-run");
|
||||
given(FailureKind.UNKNOWN, TEAM, "mine");
|
||||
when(authority.currentUserTeamId()).thenReturn(null);
|
||||
|
||||
assertThat(service.list(null, null, 50)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCallerWhoseTeamCannotBeResolvedCannotActOnAnUnteamedRow() {
|
||||
FileRunEvent unteamed = given(FailureKind.UNKNOWN, null, "someone-elses-adhoc-run");
|
||||
when(authority.currentUserTeamId()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(unteamed.id(), "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
// Not a distinct "no team" reason: that would confirm the id exists.
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
|
||||
assertThat(store.find(unteamed.id(), null).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDisabledFallsBackToTheUnteamedRowsWithoutConsultingTheAuthority() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(false);
|
||||
FailureActionRegistry registry =
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store)));
|
||||
FileRunEventService unsecured =
|
||||
new FileRunEventService(store, registry, authority, userService, props);
|
||||
|
||||
given(FailureKind.UNKNOWN, null, "unteamed");
|
||||
given(FailureKind.UNKNOWN, TEAM, "teamed");
|
||||
|
||||
assertThat(unsecured.list(null, null, 50))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("unteamed");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the action registry")
|
||||
class Registry {
|
||||
|
||||
@Test
|
||||
void refusesToStartWhenAKindDeclaresAnActionWithNoHandler() {
|
||||
// A missing handler would otherwise surface as a button that 400s in production.
|
||||
FailureActionRegistry incomplete =
|
||||
new FailureActionRegistry(List.of(new AcknowledgeAction(store)));
|
||||
|
||||
assertThatThrownBy(incomplete::verifyEveryDeclaredActionHasAHandler)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("DISMISS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesTwoHandlersForTheSameAction() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new FailureActionRegistry(
|
||||
List.of(
|
||||
new AcknowledgeAction(store),
|
||||
new AcknowledgeAction(store))))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("ACKNOWLEDGE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsACompleteSetOfHandlers() {
|
||||
FailureActionRegistry complete =
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store)));
|
||||
|
||||
complete.verifyEveryDeclaredActionHasAHandler();
|
||||
|
||||
for (FailureActionId id : FailureActionId.values()) {
|
||||
assertThat(complete.find(id)).isPresent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* {@link FileRunEventStore} against the real JPQL on a real (H2) database. Every other test in this
|
||||
* package substitutes {@link InMemoryFileRunEventRepository}, whose Java filters would keep passing
|
||||
* if the queries themselves regressed — the team-isolation clause could be deleted from the JPQL
|
||||
* and no in-memory test would notice.
|
||||
*
|
||||
* <p>{@code NOT_SUPPORTED} suspends {@code @DataJpaTest}'s per-test transaction, so every store
|
||||
* call commits in its own transaction as at runtime. That is what makes the stale-snapshot test
|
||||
* meaningful: inside one shared persistence context, both reads would hand back the same managed
|
||||
* instance and the bug could not show.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class FileRunEventStoreDbTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
private static final Long OTHER_TEAM = 9L;
|
||||
|
||||
@Autowired private FileRunEventRepository repository;
|
||||
|
||||
private FileRunEventStore store;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new FileRunEventStore(repository);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void wipe() {
|
||||
repository.deleteAllInBatch();
|
||||
}
|
||||
|
||||
private RecordFailure failure(FailureKind kind, Long teamId, String fileId) {
|
||||
return new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"author@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
fileId,
|
||||
"detail");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("team isolation is enforced by the query, including the unteamed rows")
|
||||
void teamIsolationIsEnforcedBySql() {
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "ours"));
|
||||
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs"));
|
||||
store.record(failure(FailureKind.UNKNOWN, null, "unteamed"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("ours");
|
||||
// A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is
|
||||
// never true, which is what the explicit null branch in the JPQL exists for.
|
||||
assertThat(store.list(null, null, null, 10))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("unteamed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a fold lands on the row's current state, not the caller's snapshot")
|
||||
void foldTargetsTheCurrentRowNotACallersSnapshot() {
|
||||
// The lost-update shape: record reads the row, a reviewer dismisses it, then the fold
|
||||
// applies. A save() of the stale snapshot would merge status = NEW back over the dismiss.
|
||||
FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
|
||||
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
|
||||
|
||||
FileRunEvent folded =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
|
||||
|
||||
assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
|
||||
assertThat(folded.occurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a dismiss landing mid-fold is not reverted by the folder's stale snapshot")
|
||||
void aDismissLandingMidFoldIsNotReverted() {
|
||||
// The reviewer's repro, made deterministic: the fold's read happens, then a dismiss lands,
|
||||
// then the fold applies. The old save()-based absorb merged the pre-dismiss snapshot back
|
||||
// over the row, reverting status to NEW and erasing the reviewer's action.
|
||||
FileRunEvent event = store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
|
||||
AtomicBoolean interleaved = new AtomicBoolean();
|
||||
FileRunEventRepository interleaving =
|
||||
(FileRunEventRepository)
|
||||
Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[] {FileRunEventRepository.class},
|
||||
(proxy, method, args) -> {
|
||||
Object result;
|
||||
try {
|
||||
result = method.invoke(repository, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
if ("findByTeamAndDedupKey".equals(method.getName())
|
||||
&& interleaved.compareAndSet(false, true)) {
|
||||
store.applyStatus(
|
||||
event.id(),
|
||||
TEAM,
|
||||
FileRunEventStatus.DISMISSED,
|
||||
"reviewer@example.com");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
FileRunEvent folded =
|
||||
new FileRunEventStore(interleaving)
|
||||
.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f"));
|
||||
|
||||
assertThat(folded.status()).isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
assertThat(folded.statusActor()).isEqualTo("reviewer@example.com");
|
||||
assertThat(folded.occurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a row deleted between the dedup read and the fold is re-inserted, not lost")
|
||||
void aVanishedRowIsReinsertedRatherThanLost() {
|
||||
// Retention or a manual purge can delete the row a fold was about to land on. The fold
|
||||
// then matches nothing, and treating that as an error would drop the incident entirely:
|
||||
// the recorder swallows what record() throws, leaving the failure as only a log line.
|
||||
FileRunEvent first = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
|
||||
AtomicBoolean deleted = new AtomicBoolean();
|
||||
FileRunEventRepository vanishing =
|
||||
(FileRunEventRepository)
|
||||
Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[] {FileRunEventRepository.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("fold".equals(method.getName())
|
||||
&& deleted.compareAndSet(false, true)) {
|
||||
repository.deleteById(first.id());
|
||||
}
|
||||
try {
|
||||
return method.invoke(repository, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
});
|
||||
|
||||
FileRunEvent replacement =
|
||||
new FileRunEventStore(vanishing).record(failure(FailureKind.UNKNOWN, TEAM, "f"));
|
||||
|
||||
assertThat(replacement.id()).isNotEqualTo(first.id());
|
||||
assertThat(replacement.occurrences()).isEqualTo(1);
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same broken file folds across runs, and distinct files do not")
|
||||
void repeatsFoldOnTheDocumentNotTheRun() {
|
||||
// The point of the rollup. Each sweep starts a new run, so keying on the run id alone
|
||||
// meant a file that failed every sweep opened a new incident every sweep, and several
|
||||
// files failing in one run collapsed into one. Both are wrong; the document is the key.
|
||||
RecordFailure firstSweep = locked("run-1", "file-hash-a");
|
||||
RecordFailure secondSweep = locked("run-2", "file-hash-a");
|
||||
RecordFailure otherFile = locked("run-1", "file-hash-b");
|
||||
|
||||
store.record(firstSweep);
|
||||
store.record(otherFile);
|
||||
FileRunEvent folded = store.record(secondSweep);
|
||||
|
||||
assertThat(folded.occurrences()).isEqualTo(2);
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.as("one incident per document, however many runs it failed in")
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactlyInAnyOrder("file-hash-a", "file-hash-b");
|
||||
}
|
||||
|
||||
private RecordFailure locked(String runId, String fileId) {
|
||||
return RecordFailure.forRun(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
TEAM,
|
||||
null,
|
||||
"policy-1",
|
||||
runId,
|
||||
fileId,
|
||||
"locked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the kind filter applies before the limit, not after")
|
||||
void kindFilterAppliesBeforeTheLimit() {
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "old-unknown"));
|
||||
for (int i = 0; i < 3; i++) {
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i));
|
||||
}
|
||||
|
||||
assertThat(store.list(TEAM, null, "UNKNOWN", 1))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("old-unknown");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two racing closes resolve to one winner")
|
||||
void twoClosesResolveToOneWinner() {
|
||||
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
|
||||
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "first@example.com");
|
||||
|
||||
// The second close passed the service's terminal() pre-check in its own request; the
|
||||
// guarded UPDATE is what refuses it here.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
store.applyStatus(
|
||||
event.id(),
|
||||
TEAM,
|
||||
FileRunEventStatus.DISMISSED,
|
||||
"second@example.com"))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().statusActor())
|
||||
.isEqualTo("first@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("losing a delete race reports the row missing, not closed")
|
||||
void aDeletedRowIsReportedMissingRatherThanClosed() {
|
||||
// Both refusals come from the same guarded UPDATE returning zero rows, so the store has to
|
||||
// tell them apart afterwards. Getting it wrong answers 400 for a row that is simply gone,
|
||||
// where every other "no such event" answers 404.
|
||||
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
|
||||
repository.deleteById(event.id());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
store.applyStatus(
|
||||
event.id(),
|
||||
TEAM,
|
||||
FileRunEventStatus.DISMISSED,
|
||||
"reviewer@example.com"))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a reopened incident only flips from RESOLVED, never from DISMISSED")
|
||||
void reopenFlipsOnlyResolved() {
|
||||
FileRunEvent resolved = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
|
||||
store.applyStatus(resolved.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
|
||||
|
||||
FileRunEvent reopened = store.record(failure(FailureKind.UNKNOWN, TEAM, "resolved-file"));
|
||||
|
||||
assertThat(reopened.status()).isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(reopened.statusActor()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the unique constraint exists in the DDL, not only in the entity annotation")
|
||||
void duplicateIncidentInsertTripsTheUniqueConstraint() {
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "f"));
|
||||
String dedupKey = failure(FailureKind.UNKNOWN, TEAM, "f").dedupKey();
|
||||
|
||||
FileRunEventEntity duplicate = new FileRunEventEntity();
|
||||
duplicate.setId(UUID.randomUUID().toString());
|
||||
duplicate.setTeamId(TEAM);
|
||||
duplicate.setKindId(FailureKind.UNKNOWN.getId());
|
||||
duplicate.setStage(FailureStage.INTERNAL);
|
||||
duplicate.setSeverity(FailureSeverity.ERROR);
|
||||
duplicate.setScope(FailureScope.RUN);
|
||||
duplicate.setOrigin(FailureOrigin.POLICY);
|
||||
duplicate.setDedupKey(dedupKey);
|
||||
duplicate.setOccurrences(1);
|
||||
duplicate.setStatus(FileRunEventStatus.NEW);
|
||||
duplicate.setCreatedAt(Instant.now());
|
||||
duplicate.setLastSeenAt(Instant.now());
|
||||
|
||||
assertThatThrownBy(() -> repository.saveAndFlush(duplicate))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@AutoConfigurationPackage
|
||||
static class TestApp {}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests for {@link FileRunEventStore}, driven through a hand-written in-memory repository so the
|
||||
* rollup rules are tested directly and fast. The rollup is the load-bearing part: without it,
|
||||
* Dismiss means nothing for a source that re-lists the same failing file on every sweep.
|
||||
*/
|
||||
class FileRunEventStoreTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
private static final Long OTHER_TEAM = 9L;
|
||||
|
||||
private InMemoryFileRunEventRepository repository;
|
||||
private FileRunEventStore store;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = new InMemoryFileRunEventRepository();
|
||||
store = new FileRunEventStore(repository);
|
||||
}
|
||||
|
||||
private RecordFailure failure(FailureKind kind, Long teamId, String fileId, String detail) {
|
||||
return new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"ethan@example.com",
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
fileId,
|
||||
detail);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recording a new failure")
|
||||
class Insert {
|
||||
|
||||
@Test
|
||||
void persistsEveryFieldAndSnapshotsTheRegistryFacets() {
|
||||
FileRunEvent event =
|
||||
store.record(
|
||||
failure(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
TEAM,
|
||||
"file-1",
|
||||
"locked"));
|
||||
|
||||
assertThat(event.id()).isNotBlank();
|
||||
assertThat(event.teamId()).isEqualTo(TEAM);
|
||||
assertThat(event.actor()).isEqualTo("ethan@example.com");
|
||||
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
assertThat(event.policyId()).isEqualTo("policy-1");
|
||||
assertThat(event.runId()).isEqualTo("run-1");
|
||||
assertThat(event.fileId()).isEqualTo("file-1");
|
||||
assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
|
||||
assertThat(event.status()).isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(event.occurrences()).isEqualTo(1);
|
||||
assertThat(event.createdAt()).isNotNull();
|
||||
assertThat(event.lastSeenAt()).isNotNull();
|
||||
|
||||
// Snapshotted, not derived: re-classifying a kind later must not rewrite this row.
|
||||
assertThat(event.stage()).isEqualTo(FailureStage.INPUT);
|
||||
assertThat(event.severity()).isEqualTo(FailureSeverity.ERROR);
|
||||
assertThat(event.scope()).isEqualTo(FailureScope.FILE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheRawDetailVerbatimForAnUnknownKind() {
|
||||
// For UNKNOWN this string is the only diagnostic there is, so it has to reach the row
|
||||
// unaltered. Redaction itself has its own tests.
|
||||
String raw = "Policy run failed: java.lang.IllegalStateException: pool exhausted";
|
||||
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, null, raw));
|
||||
|
||||
assertThat(event.detail()).isEqualTo(raw);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the rollup")
|
||||
class Rollup {
|
||||
|
||||
@Test
|
||||
void foldsARepeatIntoTheOpenIncidentInsteadOfInsertingAgain() {
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "first"));
|
||||
FileRunEvent second =
|
||||
store.record(
|
||||
failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "again"));
|
||||
|
||||
assertThat(second.occurrences()).isEqualTo(2);
|
||||
assertThat(repository.rows).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshesTheDetailSoTheNewestDiagnosticIsTheOneOnShow() {
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "old message"));
|
||||
FileRunEvent second =
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "new message"));
|
||||
|
||||
assertThat(second.detail()).isEqualTo("new message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsADifferentFileAsItsOwnIncident() {
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-2", "b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsADifferentKindAsItsOwnIncident() {
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "file-1", "b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverFoldsAcrossTeams() {
|
||||
// Two tenants hitting the same problem are two incidents, each owned by its own team.
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "file-1", "a"));
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, OTHER_TEAM, "file-1", "b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void absorbsARepeatOntoADismissedRowWithoutReopeningIt() {
|
||||
// A snapshot-mode source re-lists every file each sweep, so without absorption a
|
||||
// dismissed failure would reappear as a new row every time.
|
||||
FileRunEvent first =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
|
||||
store.applyStatus(
|
||||
first.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer@example.com");
|
||||
|
||||
FileRunEvent repeat =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(1);
|
||||
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
assertThat(repeat.occurrences()).isEqualTo(2);
|
||||
assertThat(repeat.statusActor()).isEqualTo("reviewer@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reopensAResolvedRowBecauseTheProblemHasComeBack() {
|
||||
// The counterweight to absorption, so a recurrence still surfaces.
|
||||
FileRunEvent first =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
|
||||
store.applyStatus(first.id(), TEAM, FileRunEventStatus.RESOLVED, "system");
|
||||
|
||||
FileRunEvent repeat =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(1);
|
||||
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(repeat.occurrences()).isEqualTo(2);
|
||||
// The previous closer no longer owns a reopened incident.
|
||||
assertThat(repeat.statusActor()).isNull();
|
||||
assertThat(repeat.statusAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAnAcknowledgedRowAcknowledgedOnRepeat() {
|
||||
FileRunEvent first =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a"));
|
||||
store.applyStatus(first.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "owner");
|
||||
|
||||
FileRunEvent repeat =
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "b"));
|
||||
|
||||
assertThat(repeat.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(repeat.statusActor()).isEqualTo("owner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSweepWhereEveryFileFailsProducesARowPerFile() {
|
||||
// Each resolved input gets its own run, so a 400-file sweep is 400 incidents rather
|
||||
// than one with a counter of 400. Grouping for display is a read-time concern.
|
||||
for (int i = 0; i < 50; i++) {
|
||||
store.record(
|
||||
new RecordFailure(
|
||||
FailureKind.UNKNOWN,
|
||||
FailureOrigin.POLICY,
|
||||
TEAM,
|
||||
"ethan@example.com",
|
||||
"policy-1",
|
||||
"run-" + i,
|
||||
null,
|
||||
"file-" + i,
|
||||
"OCR unavailable"));
|
||||
}
|
||||
|
||||
assertThat(repository.rows).hasSize(50);
|
||||
assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsEachFileAsItsOwnIncidentEvenWhenNoFileIdWasCaptured() {
|
||||
// The producer path cannot always name a file, and a FILE-scoped kind then falls back
|
||||
// to
|
||||
// the run. Without that, every locked file under one policy would fold into one row.
|
||||
store.record(withoutFileId("run-a"));
|
||||
store.record(withoutFileId("run-b"));
|
||||
|
||||
assertThat(repository.rows).hasSize(2);
|
||||
assertThat(repository.rows.values()).allMatch(row -> row.getOccurrences() == 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRetryOfTheSameRunStillFoldsWhenNoFileIdWasCaptured() {
|
||||
// The flip side: the fallback must not turn a genuine repeat into a second row.
|
||||
store.record(withoutFileId("run-a"));
|
||||
store.record(withoutFileId("run-a"));
|
||||
|
||||
assertThat(repository.rows).hasSize(1);
|
||||
assertThat(repository.rows.values().iterator().next().getOccurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void foldsIntoTheWinnersRowWhenAConcurrentWriterInsertedFirst() {
|
||||
// Two runs failing identically at once: both read, find nothing, and try to insert. The
|
||||
// loser's insert trips the unique constraint and has to fold rather than fail.
|
||||
RecordFailure command = failure(FailureKind.UNKNOWN, TEAM, "file-1", "OCR unavailable");
|
||||
FileRunEventEntity winner = rowFor(command);
|
||||
repository.beforeNextSave = () -> repository.rows.put(winner.getId(), winner);
|
||||
|
||||
FileRunEvent result = store.record(command);
|
||||
|
||||
assertThat(repository.rows).hasSize(1);
|
||||
assertThat(result.id()).isEqualTo(winner.getId());
|
||||
assertThat(result.occurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** A row as the winning writer would have inserted it, for the race above. */
|
||||
private FileRunEventEntity rowFor(RecordFailure command) {
|
||||
FileRunEventEntity entity = new FileRunEventEntity();
|
||||
entity.setId("winner");
|
||||
entity.setTeamId(command.teamId());
|
||||
entity.setKindId(command.kind().getId());
|
||||
entity.setStage(command.kind().getStage());
|
||||
entity.setSeverity(command.kind().getSeverity());
|
||||
entity.setScope(command.kind().getScope());
|
||||
entity.setOrigin(command.origin());
|
||||
entity.setDedupKey(command.dedupKey());
|
||||
entity.setOccurrences(1);
|
||||
entity.setStatus(FileRunEventStatus.NEW);
|
||||
entity.setCreatedAt(java.time.Instant.now());
|
||||
entity.setLastSeenAt(java.time.Instant.now());
|
||||
return entity;
|
||||
}
|
||||
|
||||
private RecordFailure withoutFileId(String runId) {
|
||||
return new RecordFailure(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
FailureOrigin.POLICY,
|
||||
TEAM,
|
||||
"ethan@example.com",
|
||||
"policy-1",
|
||||
runId,
|
||||
null,
|
||||
null,
|
||||
"The PDF Document is passworded");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reading")
|
||||
class Reading {
|
||||
|
||||
@Test
|
||||
void neverReturnsAnotherTeamsRows() {
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a"));
|
||||
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 50))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("mine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsANullTeamAsTheUnteamedRows() {
|
||||
// Login-disabled deployments have no team; a plain `= null` comparison would return
|
||||
// nothing, which is why the query special-cases it.
|
||||
store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a"));
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b"));
|
||||
|
||||
assertThat(store.list(null, null, null, 50))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("unteamed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByStatus() {
|
||||
FileRunEvent open = store.record(failure(FailureKind.UNKNOWN, TEAM, "open", "a"));
|
||||
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b"));
|
||||
store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me");
|
||||
|
||||
assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50))
|
||||
.extracting(FileRunEvent::fileId)
|
||||
.containsExactly("open");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findScopesByTeamSoAnIdFromAnotherTeamIsInvisible() {
|
||||
FileRunEvent theirs = store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "x", "a"));
|
||||
|
||||
assertThat(store.find(theirs.id(), TEAM)).isEmpty();
|
||||
assertThat(store.find(theirs.id(), OTHER_TEAM)).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("dedup keys")
|
||||
class Keys {
|
||||
|
||||
@Test
|
||||
void areDeterministicForTheSameFailure() {
|
||||
RecordFailure command = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f", "a");
|
||||
|
||||
assertThat(command.dedupKey()).isEqualTo(command.dedupKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differByKindAndByScopeReference() {
|
||||
RecordFailure a = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "x");
|
||||
RecordFailure b = failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f2", "x");
|
||||
RecordFailure c = failure(FailureKind.UNKNOWN, TEAM, "f1", "x");
|
||||
|
||||
assertThat(a.dedupKey()).isNotEqualTo(b.dedupKey());
|
||||
assertThat(a.dedupKey()).isNotEqualTo(c.dedupKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void areFixedWidthSoAnyLengthOfScopeReferenceFitsTheIndex() {
|
||||
String longKey = "s3://bucket/" + "nested/".repeat(500) + "file.pdf";
|
||||
RecordFailure command =
|
||||
failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, longKey, "x");
|
||||
|
||||
assertThat(command.dedupKey()).hasSize(64);
|
||||
}
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
/**
|
||||
* Minimal stand-in for the JPA repository: enough to exercise the store's branching without a
|
||||
* database. Ordering mirrors the real queries (newest first) since the rollup relies on it, and
|
||||
* {@link #save} enforces the {@code (team_id, dedup_key)} unique constraint the real table
|
||||
* declares.
|
||||
*/
|
||||
class InMemoryFileRunEventRepository implements FileRunEventRepository {
|
||||
|
||||
final Map<String, FileRunEventEntity> rows = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Runs once at the start of the next {@link #save}, so a test can interleave a competing writer
|
||||
* between the store's read and its insert.
|
||||
*/
|
||||
Runnable beforeNextSave;
|
||||
|
||||
private static boolean sameTeam(FileRunEventEntity entity, Long teamId) {
|
||||
return teamId == null ? entity.getTeamId() == null : teamId.equals(entity.getTeamId());
|
||||
}
|
||||
|
||||
private List<FileRunEventEntity> newestFirst(List<FileRunEventEntity> matches) {
|
||||
List<FileRunEventEntity> sorted = new ArrayList<>(matches);
|
||||
sorted.sort(
|
||||
(a, b) -> {
|
||||
int byTime = b.getLastSeenAt().compareTo(a.getLastSeenAt());
|
||||
// Stable tiebreak: identical timestamps are common in fast tests.
|
||||
return byTime != 0 ? byTime : a.getId().compareTo(b.getId());
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/** The real queries page in SQL, so the fake must page too or limit tests test nothing. */
|
||||
private static List<FileRunEventEntity> page(List<FileRunEventEntity> rows, Pageable pageable) {
|
||||
return pageable == null || pageable.isUnpaged() || rows.size() <= pageable.getPageSize()
|
||||
? rows
|
||||
: rows.subList(0, pageable.getPageSize());
|
||||
}
|
||||
|
||||
private static boolean sameKind(FileRunEventEntity entity, String kindId) {
|
||||
return kindId == null || kindId.equals(entity.getKindId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeam(Long teamId, String kindId, Pageable pageable) {
|
||||
return page(
|
||||
newestFirst(
|
||||
rows.values().stream()
|
||||
.filter(e -> sameTeam(e, teamId) && sameKind(e, kindId))
|
||||
.toList()),
|
||||
pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeamAndStatus(
|
||||
Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) {
|
||||
return page(
|
||||
newestFirst(
|
||||
rows.values().stream()
|
||||
.filter(
|
||||
e ->
|
||||
sameTeam(e, teamId)
|
||||
&& e.getStatus() == status
|
||||
&& sameKind(e, kindId))
|
||||
.toList()),
|
||||
pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int fold(String id, Instant now, String detail) {
|
||||
FileRunEventEntity entity = rows.get(id);
|
||||
if (entity == null) {
|
||||
return 0;
|
||||
}
|
||||
entity.setOccurrences(entity.getOccurrences() + 1);
|
||||
entity.setLastSeenAt(now);
|
||||
if (detail != null) {
|
||||
entity.setDetail(detail);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int reopenIfResolved(String id) {
|
||||
FileRunEventEntity entity = rows.get(id);
|
||||
if (entity == null || entity.getStatus() != FileRunEventStatus.RESOLVED) {
|
||||
return 0;
|
||||
}
|
||||
entity.setStatus(FileRunEventStatus.NEW);
|
||||
entity.setStatusActor(null);
|
||||
entity.setStatusAt(null);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int applyStatusIf(
|
||||
String id,
|
||||
Long teamId,
|
||||
FileRunEventStatus target,
|
||||
String actor,
|
||||
Instant now,
|
||||
Collection<FileRunEventStatus> allowedFrom) {
|
||||
FileRunEventEntity entity = rows.get(id);
|
||||
if (entity == null
|
||||
|| !sameTeam(entity, teamId)
|
||||
|| !allowedFrom.contains(entity.getStatus())) {
|
||||
return 0;
|
||||
}
|
||||
entity.setStatus(target);
|
||||
entity.setStatusActor(actor);
|
||||
entity.setStatusAt(now);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findByTeamAndDedupKey(
|
||||
Long teamId, String dedupKey, Pageable pageable) {
|
||||
return newestFirst(
|
||||
rows.values().stream()
|
||||
.filter(e -> sameTeam(e, teamId) && dedupKey.equals(e.getDedupKey()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<FileRunEventEntity> findByIdAndTeam(String id, Long teamId) {
|
||||
return Optional.ofNullable(rows.get(id)).filter(e -> sameTeam(e, teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> S save(S entity) {
|
||||
if (beforeNextSave != null) {
|
||||
Runnable hook = beforeNextSave;
|
||||
beforeNextSave = null;
|
||||
hook.run();
|
||||
}
|
||||
boolean isInsert = !rows.containsKey(entity.getId());
|
||||
if (isInsert && clashesOnDedupKey(entity)) {
|
||||
throw new org.springframework.dao.DataIntegrityViolationException(
|
||||
"uk_file_run_events_dedup violated for " + entity.getDedupKey());
|
||||
}
|
||||
rows.put(entity.getId(), entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Mirrors the unique constraint. SQL treats NULL teams as distinct, and so does this. */
|
||||
private boolean clashesOnDedupKey(FileRunEventEntity candidate) {
|
||||
if (candidate.getTeamId() == null) {
|
||||
return false;
|
||||
}
|
||||
return rows.values().stream()
|
||||
.anyMatch(
|
||||
row ->
|
||||
candidate.getTeamId().equals(row.getTeamId())
|
||||
&& candidate.getDedupKey().equals(row.getDedupKey()));
|
||||
}
|
||||
|
||||
// ── unused JpaRepository surface ────────────────────────────────────────
|
||||
@Override
|
||||
public void flush() {}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> S saveAndFlush(S entity) {
|
||||
return save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> List<S> saveAllAndFlush(Iterable<S> entities) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllInBatch(Iterable<FileRunEventEntity> entities) {}
|
||||
|
||||
@Override
|
||||
public void deleteAllByIdInBatch(Iterable<String> ids) {}
|
||||
|
||||
@Override
|
||||
public void deleteAllInBatch() {}
|
||||
|
||||
@Override
|
||||
public FileRunEventEntity getOne(String id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRunEventEntity getById(String id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRunEventEntity getReferenceById(String id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> List<S> findAll(
|
||||
org.springframework.data.domain.Example<S> example) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> List<S> findAll(
|
||||
org.springframework.data.domain.Example<S> example,
|
||||
org.springframework.data.domain.Sort sort) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> List<S> saveAll(Iterable<S> entities) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findAll() {
|
||||
return List.copyOf(rows.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findAllById(Iterable<String> ids) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<FileRunEventEntity> findById(String id) {
|
||||
return Optional.ofNullable(rows.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return rows.containsKey(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
rows.remove(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(FileRunEventEntity entity) {
|
||||
rows.remove(entity.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllById(Iterable<? extends String> ids) {}
|
||||
|
||||
@Override
|
||||
public void deleteAll(Iterable<? extends FileRunEventEntity> entities) {}
|
||||
|
||||
@Override
|
||||
public void deleteAll() {
|
||||
rows.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRunEventEntity> findAll(org.springframework.data.domain.Sort sort) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public org.springframework.data.domain.Page<FileRunEventEntity> findAll(Pageable pageable) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> Optional<S> findOne(
|
||||
org.springframework.data.domain.Example<S> example) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> org.springframework.data.domain.Page<S> findAll(
|
||||
org.springframework.data.domain.Example<S> example, Pageable pageable) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> long count(
|
||||
org.springframework.data.domain.Example<S> example) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity> boolean exists(
|
||||
org.springframework.data.domain.Example<S> example) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends FileRunEventEntity, R> R findBy(
|
||||
org.springframework.data.domain.Example<S> example,
|
||||
Function<
|
||||
org.springframework.data.repository.query.FluentQuery
|
||||
.FetchableFluentQuery<
|
||||
S>,
|
||||
R>
|
||||
queryFunction) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Tests for {@link PolicyFailureRecorder}. Two behaviours beyond "it writes a row": the team comes
|
||||
* from the originating policy, and a store problem is swallowed rather than replacing the failure
|
||||
* being recorded.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PolicyFailureRecorderTest {
|
||||
|
||||
private static final Long TEAM = 11L;
|
||||
|
||||
@Mock private PolicyStore policyStore;
|
||||
|
||||
private InMemoryFileRunEventRepository repository;
|
||||
private FileRunEventStore store;
|
||||
private PolicyFailureRecorder recorder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = new InMemoryFileRunEventRepository();
|
||||
store = new FileRunEventStore(repository);
|
||||
recorder =
|
||||
new PolicyFailureRecorder(
|
||||
new FailureClassifier(JsonMapper.builder().build()), store, policyStore);
|
||||
}
|
||||
|
||||
private static Policy policy(String id, Long teamId) {
|
||||
return new Policy(
|
||||
id,
|
||||
"Contract redaction",
|
||||
"owner@example.com",
|
||||
true,
|
||||
List.of(),
|
||||
List.of(),
|
||||
OutputSpec.inline(),
|
||||
List.of(),
|
||||
teamId);
|
||||
}
|
||||
|
||||
private static HttpClientErrorException passwordFailure() {
|
||||
String body = "{\"type\":\"/errors/pdf-password\",\"errorCode\":\"E004\"}";
|
||||
return HttpClientErrorException.create(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Bad Request",
|
||||
new HttpHeaders(),
|
||||
body.getBytes(StandardCharsets.UTF_8),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("classifying and recording")
|
||||
class Recording {
|
||||
|
||||
@Test
|
||||
void classifiesTheCauseAndStoresTheRunsOwnMessage() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
"dana@example.com",
|
||||
null,
|
||||
"Policy run failed: locked",
|
||||
passwordFailure());
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
|
||||
assertThat(event.runId()).isEqualTo("run-1");
|
||||
assertThat(event.policyId()).isEqualTo("policy-1");
|
||||
assertThat(event.actor()).isEqualTo("dana@example.com");
|
||||
assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
|
||||
// The run's message, not the exception's: that is what the operator saw. Stored
|
||||
// verbatim, including for a kind we recognise, so the row says what actually happened.
|
||||
assertThat(event.detail()).isEqualTo("Policy run failed: locked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheMessageForAFailureItCouldNotClassify() {
|
||||
// The opposite case, and the reason the message is stored at all: with no kind to
|
||||
// describe it, this text is the only thing telling a reviewer what went wrong.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
"dana@example.com",
|
||||
null,
|
||||
"Policy run failed: something we do not recognise",
|
||||
new RuntimeException("boom"));
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
|
||||
assertThat(event.detail()).contains("something we do not recognise");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsAnUnclassifiableFailureAsUnknownRatherThanDroppingIt() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-2",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"Policy run failed: java.lang.NullPointerException",
|
||||
new RuntimeException("npe"));
|
||||
|
||||
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
|
||||
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
|
||||
assertThat(event.detail()).contains("NullPointerException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAPreDecidedKindForPathsWithNothingToClassify() {
|
||||
// A run rejected at admission threw nothing; the outcome is still known.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailureAs(
|
||||
FailureKind.UNKNOWN, "run-3", "policy-1", null, "could not be queued");
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoFilesFailingTheSameWayUnderOnePolicyAreTwoIncidents() {
|
||||
// The producer records per run and cannot name a file, so a FILE-scoped kind has only
|
||||
// the
|
||||
// run to tell two documents apart. Tested here rather than against a hand-built
|
||||
// RecordFailure, because that is what let three locked files collapse into one row.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
|
||||
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
|
||||
assertThat(events).hasSize(2);
|
||||
assertThat(events).allMatch(event -> event.occurrences() == 1);
|
||||
assertThat(events)
|
||||
.extracting(FileRunEvent::runId)
|
||||
.containsExactlyInAnyOrder("run-1", "run-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void thatSameRunFailingTwiceStaysOneIncident() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", "dana@example.com", null, "locked", passwordFailure());
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::occurrences)
|
||||
.isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("team resolution")
|
||||
class TeamResolution {
|
||||
|
||||
@Test
|
||||
void takesTheTeamFromTheOriginatingPolicy() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new RuntimeException());
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesAnAdHocRunUnteamedRatherThanGuessing() {
|
||||
// No stored policy means no team to attribute it to. Recorded unteamed rather than
|
||||
// attributed to whichever team happened to be nearby.
|
||||
recorder.recordRunFailure("run-1", null, null, null, "boom", new RuntimeException());
|
||||
|
||||
assertThat(store.list(null, null, null, 10)).hasSize(1);
|
||||
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void survivesAPolicyLookupFailure() {
|
||||
when(policyStore.get(anyString())).thenThrow(new RuntimeException("db down"));
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
recorder.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"boom",
|
||||
new RuntimeException()))
|
||||
.doesNotThrowAnyException();
|
||||
// Still recorded, just unteamed: a lookup problem must not lose the incident.
|
||||
assertThat(store.list(null, null, null, 10)).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recording is best effort")
|
||||
class BestEffort {
|
||||
|
||||
@Test
|
||||
void aStoreFailureIsSwallowedSoItCannotReplaceTheRealFailure() {
|
||||
// If this threw, the engine's catch block would report a store error instead of the
|
||||
// password problem that actually stopped the run.
|
||||
FileRunEventStore broken = mock(FileRunEventStore.class);
|
||||
when(broken.record(any())).thenThrow(new RuntimeException("event store unavailable"));
|
||||
lenient()
|
||||
.when(policyStore.get("policy-1"))
|
||||
.thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
PolicyFailureRecorder fragile =
|
||||
new PolicyFailureRecorder(
|
||||
new FailureClassifier(JsonMapper.builder().build()),
|
||||
broken,
|
||||
policyStore);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
fragile.recordRunFailure(
|
||||
"run-1",
|
||||
"policy-1",
|
||||
null,
|
||||
null,
|
||||
"Policy run failed: locked",
|
||||
passwordFailure()))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullCauseDoesNotBreakRecording() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "no cause", null))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
|
||||
.isEqualTo(FailureKind.UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("repeats")
|
||||
class Repeats {
|
||||
|
||||
@Test
|
||||
void twoIdenticalRunFailuresFoldIntoOneIncident() {
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
|
||||
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.getFirst().occurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoDifferentRunsAreSeparateIncidents() {
|
||||
// UNKNOWN is RUN-scoped, so the run id is what distinguishes them.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, null, "boom", new IOException("x"));
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", null, null, "boom", new IOException("x"));
|
||||
|
||||
assertThat(store.list(TEAM, null, null, 10)).hasSize(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The privacy contract: a recorded failure carries no document identity of its own. There is no
|
||||
* name column and the dedup key is built only from opaque ids, so nothing here derives from what a
|
||||
* document is called.
|
||||
*
|
||||
* <p>The message itself is stored verbatim. It is the user's own error about their own file, and
|
||||
* scrubbing it made rows harder to act on without making them meaningfully safer.
|
||||
*/
|
||||
@DisplayName("a recorded failure carries no document identity of its own")
|
||||
class RecordFailurePrivacyTest {
|
||||
|
||||
private static RecordFailure withDetail(String detail) {
|
||||
return RecordFailure.forRun(
|
||||
FailureKind.UNKNOWN, 1L, "dana@example.com", "policy-1", "run-1", null, detail);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toleratesAnAbsentMessage() {
|
||||
assertThat(withDetail(null).detail()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasNoFileNameFieldAtAll() {
|
||||
// Structural, not behavioural: if a fileName component is ever added back, this fails.
|
||||
assertThat(List.of(RecordFailure.class.getRecordComponents()))
|
||||
.extracting(RecordComponent::getName)
|
||||
.doesNotContain("fileName");
|
||||
assertThat(List.of(FileRunEventEntity.class.getDeclaredFields()))
|
||||
.extracting(Field::getName)
|
||||
.doesNotContain("fileName");
|
||||
assertThat(List.of(FileRunEventView.class.getRecordComponents()))
|
||||
.extracting(RecordComponent::getName)
|
||||
.doesNotContain("fileName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedupKeyIsBuiltOnlyFromOpaqueIdentifiers() {
|
||||
// Two files under the same policy hash differently (so they stay separate incidents), but
|
||||
// the inputs are ids, so the hash is not reversible to a document name.
|
||||
RecordFailure a =
|
||||
new RecordFailure(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
FailureOrigin.POLICY,
|
||||
1L,
|
||||
null,
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
"file-aaa",
|
||||
"x");
|
||||
RecordFailure b =
|
||||
new RecordFailure(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
FailureOrigin.POLICY,
|
||||
1L,
|
||||
null,
|
||||
"policy-1",
|
||||
"run-1",
|
||||
null,
|
||||
"file-bbb",
|
||||
"x");
|
||||
|
||||
assertThat(a.dedupKey()).isNotEqualTo(b.dedupKey());
|
||||
assertThat(a.dedupKey()).hasSize(64).isEqualTo(a.dedupKey());
|
||||
assertThat(a.scopeRef()).doesNotContain(".pdf");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the detail cap")
|
||||
class DetailCap {
|
||||
|
||||
@Test
|
||||
void capsAnOversizedMessageAtTheLimitIncludingTheEllipsis() {
|
||||
String stored = withDetail("x".repeat(5_000)).detail();
|
||||
|
||||
assertThat(stored).hasSize(2_000).endsWith("…");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesAMessageAtTheLimitAlone() {
|
||||
String atLimit = "x".repeat(2_000);
|
||||
|
||||
assertThat(withDetail(atLimit).detail()).isEqualTo(atLimit);
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverCutsBetweenTheHalvesOfASurrogatePair() {
|
||||
// A string of astral-plane characters: every char is half of a surrogate pair, so a
|
||||
// blind substring at the cap has a 50% chance of storing invalid UTF-16.
|
||||
String stored = withDetail("\uD835\uDC9C".repeat(3_000)).detail();
|
||||
|
||||
assertThat(new String(stored.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))
|
||||
.as("a round-trip through UTF-8 mangles an unpaired surrogate")
|
||||
.isEqualTo(stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -213,7 +213,7 @@ class PolicyControllerTest {
|
||||
}
|
||||
|
||||
private static PolicyRunHandle handle(String runId) {
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep());
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null);
|
||||
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ class PolicyControllerTest {
|
||||
@Test
|
||||
@DisplayName("returns the run view when present")
|
||||
void found() {
|
||||
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep());
|
||||
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null);
|
||||
when(runRegistry.get("run-3")).thenReturn(run);
|
||||
|
||||
ResponseEntity<PolicyRunView> response = controller.status("run-3");
|
||||
@@ -343,9 +343,9 @@ class PolicyControllerTest {
|
||||
@Test
|
||||
@DisplayName("excludes ad-hoc runs and runs owned by others")
|
||||
void filtersRuns() {
|
||||
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep());
|
||||
PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep());
|
||||
PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep());
|
||||
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null);
|
||||
PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep(), null);
|
||||
PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep(), null);
|
||||
when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored));
|
||||
|
||||
// ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned
|
||||
|
||||
+90
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.engine;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
@@ -10,9 +11,11 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -22,6 +25,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -50,6 +54,7 @@ import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.proprietary.failure.PolicyFailureRecorder;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
@@ -85,6 +90,7 @@ class PolicyEngineTest {
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
@Mock private PolicyFailureRecorder failureRecorder;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@@ -112,6 +118,7 @@ class PolicyEngineTest {
|
||||
executor,
|
||||
taskManager,
|
||||
registry,
|
||||
failureRecorder,
|
||||
fileStorage,
|
||||
jobOwnershipService,
|
||||
List.of(sink, recordingSink),
|
||||
@@ -212,6 +219,52 @@ class PolicyEngineTest {
|
||||
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
|
||||
verify(taskManager).setError(eq(runId), anyString());
|
||||
verify(taskManager, never()).setComplete(runId);
|
||||
// A failed run is recorded durably, so an admin can see it after the in-memory run expires.
|
||||
verify(failureRecorder)
|
||||
.recordRunFailure(
|
||||
eq(runId), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordingAFailureNeverChangesTheRunsOutcome() throws Exception {
|
||||
// Recording is best-effort: losing the incident row is bad, but turning a classified
|
||||
// failure
|
||||
// into a different, confusing failure is worse.
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
|
||||
doThrow(new RuntimeException("event store unavailable"))
|
||||
.when(failureRecorder)
|
||||
.recordRunFailure(
|
||||
anyString(), any(), any(), any(), anyString(), any(Throwable.class));
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
|
||||
// The original failure message survives, rather than being replaced by the store's.
|
||||
assertTrue(run.getError().contains("boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulRunRecordsNoFailureEvent() throws Exception {
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenReturn(new StoredFile("file-1", 7L));
|
||||
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP)
|
||||
.completion()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
|
||||
verifyNoInteractions(failureRecorder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -407,6 +460,43 @@ class PolicyEngineTest {
|
||||
assertFalse(engine.cancel("does-not-exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingARunRecordsNoFailureEvent() throws Exception {
|
||||
// A cancellation is an intended outcome, not an incident. Recording one would put a row in
|
||||
// front of an admin describing something a user deliberately did.
|
||||
//
|
||||
// The cancel happens while the tool call is in flight, held on a latch: an earlier version
|
||||
// of this test awaited completion first, and cancel() on a finished run is a documented
|
||||
// no-op, so it asserted nothing.
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
CountDownLatch toolEntered = new CountDownLatch(1);
|
||||
CountDownLatch releaseTool = new CountDownLatch(1);
|
||||
when(internalApiClient.post(eq(ROTATE), any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
toolEntered.countDown();
|
||||
assertTrue(
|
||||
releaseTool.await(10, TimeUnit.SECONDS),
|
||||
"test never released the tool call");
|
||||
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
|
||||
});
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenReturn(new StoredFile("file-1", 7L));
|
||||
|
||||
PolicyRunHandle handle =
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP);
|
||||
assertTrue(toolEntered.await(10, TimeUnit.SECONDS), "run never reached the tool call");
|
||||
assertTrue(engine.cancel(handle.runId()), "cancel was a no-op, so this asserts nothing");
|
||||
releaseTool.countDown();
|
||||
handle.completion().get(10, TimeUnit.SECONDS);
|
||||
|
||||
verifyNoInteractions(failureRecorder);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static PipelineDefinition definition(PipelineStep... steps) {
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -299,7 +300,11 @@ class PolicyExecutorTest {
|
||||
definition(new PipelineStep(compress, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("img", "image.png"))),
|
||||
PolicyProgressListener.NOOP));
|
||||
assertTrue(ex.getMessage().contains("image.png"));
|
||||
// Names the rejected extension, never the document. This message becomes the run's error
|
||||
// and
|
||||
// is persisted on the failure record, which holds no document name.
|
||||
assertTrue(ex.getMessage().contains("png"));
|
||||
assertFalse(ex.getMessage().contains("image.png"));
|
||||
// Type check happens before any dispatch.
|
||||
verify(internalApiClient, never()).post(anyString(), any());
|
||||
}
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ class PolicyRunRegistryTest {
|
||||
|
||||
private PolicyRun register(String runId) {
|
||||
PolicyRun run =
|
||||
new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), List.of()));
|
||||
new PolicyRun(
|
||||
runId, null, new PipelineDefinition(runId, List.of(), List.of()), null);
|
||||
registry.register(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
+12
-12
@@ -78,13 +78,13 @@ class PolicyRunnerTest {
|
||||
@Test
|
||||
void runsOnceWithNoFilesWhenThePolicyHasNoSources() {
|
||||
Policy policy = policy(List.of());
|
||||
when(policyEngine.runPolicy(eq(policy), any(), any()))
|
||||
when(policyEngine.runPolicy(eq(policy), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
ArgumentCaptor<PolicyInputs> inputs = ArgumentCaptor.forClass(PolicyInputs.class);
|
||||
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any());
|
||||
verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any(), any());
|
||||
assertTrue(inputs.getValue().primary().isEmpty());
|
||||
// Ledger hygiene still runs: rows recorded for a generator policy's folder outputs
|
||||
// are pruned by its own sweeps rather than accumulating until the policy is deleted.
|
||||
@@ -137,12 +137,12 @@ class PolicyRunnerTest {
|
||||
List.of(
|
||||
ResolvedInput.of(PolicyInputs.of(List.of())),
|
||||
ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any());
|
||||
verify(policyEngine, times(2)).runPolicy(eq(policy), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -150,11 +150,11 @@ class PolicyRunnerTest {
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
Policy policy = policy(List.of(spec));
|
||||
AtomicBoolean outcome = new AtomicBoolean(false);
|
||||
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
|
||||
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), null, outcome::set);
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
when(policyEngine.runPolicy(any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", completion));
|
||||
|
||||
runner.run(policy);
|
||||
@@ -171,11 +171,11 @@ class PolicyRunnerTest {
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
Policy policy = policy(List.of(spec));
|
||||
AtomicBoolean outcome = new AtomicBoolean(true);
|
||||
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set);
|
||||
ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), null, outcome::set);
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit));
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
when(policyEngine.runPolicy(any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", completion));
|
||||
|
||||
runner.run(policy);
|
||||
@@ -224,12 +224,12 @@ class PolicyRunnerTest {
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any()))
|
||||
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy, SweepKind.LIGHT);
|
||||
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any());
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any(), any());
|
||||
verify(processedLedger, never()).markSeen(any(), any());
|
||||
verify(processedLedger, never()).deleteUnseen(any(), anyLong());
|
||||
}
|
||||
@@ -244,12 +244,12 @@ class PolicyRunnerTest {
|
||||
when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone"));
|
||||
when(folderSource.resolve(eq(healthy), any()))
|
||||
.thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of()))));
|
||||
when(policyEngine.runPolicy(any(), any(), any()))
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any()); // healthy source still ran
|
||||
verify(policyEngine).runPolicy(eq(policy), any(), any(), any()); // healthy source still ran
|
||||
verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -12,14 +12,12 @@ RUN apt-get update \
|
||||
|
||||
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
|
||||
WORKDIR /app/engine
|
||||
|
||||
COPY pyproject.toml uv.lock .env ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
|
||||
COPY engine/scripts/ ./scripts/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
COPY src/ ./src/
|
||||
uv sync --frozen --no-dev --group engine
|
||||
|
||||
COPY engine/src/ ./src/
|
||||
WORKDIR /app
|
||||
COPY Taskfile.yml ./
|
||||
COPY .taskfiles/ ./.taskfiles/
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY engine/pyproject.toml engine/uv.lock ./engine/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
cd engine && uv sync --frozen --group engine --group engine-dev
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV PATH="/app/engine/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["uv", "run", "uvicorn", "stirling.api.app:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
|
||||
CMD ["uv", "run", "--project", "/app/engine", "--frozen", "--group", "engine", "uvicorn", "stirling.api.app:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
|
||||
|
||||
+74
-32
@@ -1,33 +1,72 @@
|
||||
[project]
|
||||
name = "engine"
|
||||
version = "0.1.0"
|
||||
description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"cryptography>=50.0.0",
|
||||
"fastapi>=0.116.0",
|
||||
"pgvector>=0.3.6",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration
|
||||
"pydantic-ai>=1.99.0,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"sqlite-vec>=0.1.6",
|
||||
"uvicorn>=0.35.0",
|
||||
"opentelemetry-sdk>=1.39.0",
|
||||
"posthog>=3.0.0",
|
||||
]
|
||||
description = "Stirling PDF Python projects"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"anyio>=4.0.0",
|
||||
"datamodel-code-generator[ruff]>=0.26.0",
|
||||
"pytest>=8.0.0",
|
||||
"pyright>=1.1.408",
|
||||
"referencing>=0.35.0",
|
||||
"ruff>=0.14.10",
|
||||
# Runtime dependencies for the AI document engine service.
|
||||
engine = [
|
||||
"cryptography>=50.0.0",
|
||||
"fastapi>=0.141.1",
|
||||
"opentelemetry-sdk>=1.39.1",
|
||||
"pgvector>=0.5.0",
|
||||
"posthog>=7.38.3",
|
||||
"psycopg[binary,pool]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
|
||||
"pydantic-ai>=1.107.2,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"sqlite-vec>=0.1.9",
|
||||
"uvicorn>=0.52.1",
|
||||
]
|
||||
# Type checking, testing, model generation, and formatting tools for the engine.
|
||||
engine-dev = [
|
||||
"anyio>=4.14.2",
|
||||
"datamodel-code-generator[ruff]==0.64.0",
|
||||
"pyright>=1.1.411",
|
||||
"pytest>=9.1.1",
|
||||
"referencing>=0.37.0",
|
||||
"ruff==0.15.5",
|
||||
]
|
||||
# Dependencies for the Cucumber/Python integration test suite.
|
||||
cucumber = [
|
||||
"behave>=1.3.3",
|
||||
"behave-html-formatter>=0.9.10",
|
||||
"opencv-python-headless>=5.0.0.93",
|
||||
"pdf2image>=1.17.0",
|
||||
"pillow>=12.3.0",
|
||||
"pypdf[crypto]>=6.15.0",
|
||||
"qrcode[pil]>=8.2",
|
||||
"reportlab>=5.0.0",
|
||||
"requests>=2.34.2",
|
||||
]
|
||||
# Shared Python utilities used by repository scripts and CI workflows.
|
||||
tools = [
|
||||
"deep-translator>=1.11.4",
|
||||
"defusedxml>=0.7.1",
|
||||
"fonttools>=4.63.0",
|
||||
"fpdf2>=2.8.7",
|
||||
"openai>=2.53.0",
|
||||
"requests>=2.34.2",
|
||||
"tomli-w>=1.2.0",
|
||||
"tomlkit>=0.15.1",
|
||||
"urllib3>=2.7.0",
|
||||
"unoserver>=3.7",
|
||||
"weasyprint>=69.0",
|
||||
]
|
||||
# Release-signature verification dependencies.
|
||||
updater-signatures = [
|
||||
"cryptography>=50.0.0",
|
||||
]
|
||||
# Pinned repository-wide pre-commit tooling.
|
||||
pre-commit = [
|
||||
"codespell==2.4.2",
|
||||
"ruff==0.15.5",
|
||||
"tomli-w==1.2.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -36,9 +75,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
exclude = [
|
||||
"tests",
|
||||
]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = []
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
@@ -53,14 +93,16 @@ select = [
|
||||
"W",
|
||||
"RUF100",
|
||||
"UP",
|
||||
"PYI", # flake8-pyi: flags deprecated typing constructs
|
||||
"FA", # flake8-future-annotations: flags missing future annotations imports
|
||||
"BLE", # flake8-blind-except: flags bare `except Exception`
|
||||
"PYI", # flake8-pyi: flags deprecated typing constructs
|
||||
"FA", # flake8-future-annotations: flags missing future annotations imports
|
||||
"BLE", # flake8-blind-except: flags bare `except Exception`
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["stirling", "tests"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.13"
|
||||
|
||||
reportImportCycles = "warning"
|
||||
reportUnnecessaryCast = "warning"
|
||||
reportUnnecessaryTypeIgnoreComment = "warning"
|
||||
|
||||
@@ -65,6 +65,8 @@ class ToolIOWhen(ApiModel):
|
||||
|
||||
param: str
|
||||
matches: list[str]
|
||||
# The value the endpoint uses when this parameter is absent; None when it has none.
|
||||
default: str | None = None
|
||||
|
||||
|
||||
class ToolIOCase(ApiModel):
|
||||
@@ -378,7 +380,10 @@ def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
|
||||
|
||||
def _render_when(condition: dict[str, Any]) -> str:
|
||||
return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})"
|
||||
parts = [f"param={json.dumps(condition['param'])}", f"matches={json.dumps(condition['matches'])}"]
|
||||
if "default" in condition:
|
||||
parts.append(f"default={json.dumps(condition['default'])}")
|
||||
return f"ToolIOWhen({', '.join(parts)})"
|
||||
|
||||
|
||||
def _render_case(case: dict[str, Any]) -> str:
|
||||
|
||||
@@ -58,6 +58,8 @@ class ToolIOWhen(ApiModel):
|
||||
|
||||
param: str
|
||||
matches: list[str]
|
||||
# The value the endpoint uses when this parameter is absent; None when it has none.
|
||||
default: str | None = None
|
||||
|
||||
|
||||
class ToolIOCase(ApiModel):
|
||||
@@ -101,7 +103,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SIMO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])],
|
||||
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"], default="multiple")],
|
||||
produces=ToolFormat.IMAGE,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
@@ -130,15 +132,19 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["ps"])],
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["ps"], default="eps")],
|
||||
produces=ToolFormat.POSTSCRIPT,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["pcl"], default="eps")],
|
||||
produces=ToolFormat.PCL,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["xps"], default="eps")],
|
||||
produces=ToolFormat.XPS,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -151,7 +157,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.MIMO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])],
|
||||
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"], default="false")],
|
||||
produces=ToolFormat.PDF,
|
||||
arity=ToolArity.MISO,
|
||||
)
|
||||
@@ -202,7 +208,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="dryRun", matches=["true"], default="false")],
|
||||
produces=ToolFormat.JSON,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -223,7 +231,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="sidecar", matches=["true"], default="false")],
|
||||
produces=ToolFormat.ZIP,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -242,7 +252,10 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])],
|
||||
when=[
|
||||
ToolIOWhen(param="password", matches=[""], default=""),
|
||||
ToolIOWhen(param="ownerPassword", matches=[""], default=""),
|
||||
],
|
||||
produces=ToolFormat.PDF,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, assert_never
|
||||
@@ -133,7 +133,7 @@ class ConcurrencyLimitedModel(WrapperModel):
|
||||
model_settings: ModelSettings | None,
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
run_context: RunContext[Any] | None = None,
|
||||
) -> AsyncIterator[StreamedResponse]:
|
||||
) -> AsyncGenerator[StreamedResponse]:
|
||||
async with self._semaphore:
|
||||
async with super().request_stream(
|
||||
messages, model_settings, model_request_parameters, run_context
|
||||
|
||||
@@ -89,11 +89,16 @@ def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> Re
|
||||
for rule in spec.cases:
|
||||
all_hold = True
|
||||
for condition in rule.when:
|
||||
if parameters is None or condition.param not in parameters:
|
||||
if parameters is not None and condition.param in parameters:
|
||||
raw: object = parameters[condition.param]
|
||||
elif condition.default is not None:
|
||||
# The caller omitted it, so it takes the endpoint's default.
|
||||
raw = condition.default
|
||||
else:
|
||||
saw_unknown_param = True
|
||||
all_hold = False
|
||||
continue
|
||||
normalised = _normalise(parameters[condition.param])
|
||||
normalised = _normalise(raw)
|
||||
all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches)
|
||||
if all_hold:
|
||||
return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -31,7 +31,7 @@ def _client(
|
||||
settings_factory: Callable[[], AppSettings],
|
||||
*,
|
||||
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
|
||||
) -> Iterator[TestClient]:
|
||||
) -> Generator[TestClient]:
|
||||
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``."""
|
||||
previous = app.dependency_overrides.get(load_settings)
|
||||
app.dependency_overrides[load_settings] = settings_factory
|
||||
|
||||
Generated
+1099
-1348
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"editor/src/core/assets/Brand.stories.tsx :: Logos": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
@@ -886,9 +889,6 @@
|
||||
"editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [
|
||||
"button-name"
|
||||
],
|
||||
"editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
@@ -1377,8 +1377,17 @@
|
||||
"editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [
|
||||
"button-name"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [
|
||||
@@ -1395,10 +1404,15 @@
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"],
|
||||
"editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Collapsible.stories.tsx :: Default": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Drawer.stories.tsx :: Playground": [
|
||||
"aria-allowed-role",
|
||||
"color-contrast"
|
||||
@@ -1565,11 +1579,16 @@
|
||||
"editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"],
|
||||
"editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [
|
||||
"color-contrast"
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: Playground": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [
|
||||
"color-contrast"
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"],
|
||||
"editor/src/portal/components/AppShell.stories.tsx :: Mobile": [
|
||||
@@ -1951,24 +1970,34 @@
|
||||
"editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [
|
||||
"aria-progressbar-name",
|
||||
"editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [
|
||||
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [
|
||||
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
|
||||
@@ -2267,17 +2296,9 @@
|
||||
"landmark-unique"
|
||||
],
|
||||
"editor/src/portal/views/Documents.stories.tsx :: Default": [
|
||||
"aria-prohibited-attr",
|
||||
"color-contrast",
|
||||
"empty-table-header",
|
||||
"nested-interactive"
|
||||
],
|
||||
"editor/src/portal/views/Documents.stories.tsx :: Empty": [
|
||||
"aria-prohibited-attr",
|
||||
"color-contrast",
|
||||
"empty-table-header",
|
||||
"nested-interactive"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [
|
||||
"color-contrast",
|
||||
"landmark-no-duplicate-banner",
|
||||
@@ -2300,9 +2321,6 @@
|
||||
"editor/src/portal/views/Integrations.stories.tsx :: No Connections": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
@@ -2311,16 +2329,8 @@
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"],
|
||||
"editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Default": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Empty": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [
|
||||
"aria-hidden-focus"
|
||||
],
|
||||
@@ -2354,6 +2364,9 @@
|
||||
"aria-dialog-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [
|
||||
"button-name",
|
||||
"color-contrast"
|
||||
|
||||
@@ -29,7 +29,16 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's semantic token layer (--bg-surface, --onboarding-title, …).
|
||||
// The app reaches it through its style entry; without it here, components
|
||||
// styled on those variables render unthemed (e.g. transparent modal surfaces)
|
||||
// and axe measures contrast against colours the app never shows.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
// Portal element reset + typography. Scoped to .portal-scope in the app so it
|
||||
// can't leak into the editor; the decorator below adds that class around
|
||||
// portal stories only, mirroring how PortalApp mounts.
|
||||
import "@portal/theme/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
// glob, so the toolbar language switcher can flip between all languages with no
|
||||
@@ -201,6 +210,13 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// PortalApp mounts its views inside a .portal-scope wrapper, which is what
|
||||
// the portal's base.css keys its reset/typography on. Give portal stories
|
||||
// the same wrapper (and only them — the scoping exists precisely so portal
|
||||
// styles never apply to editor components).
|
||||
const isPortalStory = (context.parameters.fileName ?? "").includes(
|
||||
"/portal/",
|
||||
);
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -214,7 +230,13 @@ const withProviders: Decorator = (Story, context) => {
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
{isPortalStory ? (
|
||||
<div className="portal-scope">
|
||||
<Story />
|
||||
</div>
|
||||
) : (
|
||||
<Story />
|
||||
)}
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
@@ -229,6 +251,12 @@ const withProviders: Decorator = (Story, context) => {
|
||||
|
||||
const preview: Preview = {
|
||||
loaders: [mswLoader],
|
||||
// The scan runs once per theme (SCAN_THEME=light|dark, forwarded by
|
||||
// .storybook/vitest.config.ts); pinning the global here themes every story in
|
||||
// the run. Unset — the Storybook UI — falls back to the toolbar default.
|
||||
initialGlobals: {
|
||||
theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light",
|
||||
},
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
controls: {
|
||||
|
||||
@@ -14,6 +14,14 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
* Run with: npx vitest run --config .storybook/vitest.config.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Forwards the SCAN_THEME env var into the browser bundle, where preview.tsx
|
||||
// uses it to pin the theme global for the whole run. The Storybook dev/build
|
||||
// pipeline never sets it, so the toolbar default stays "light" there.
|
||||
define: {
|
||||
"import.meta.env.VITE_SCAN_THEME": JSON.stringify(
|
||||
process.env.SCAN_THEME ?? "",
|
||||
),
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Pre-scan every story + the preview so Vite discovers the story set's large
|
||||
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
|
||||
|
||||
@@ -4869,12 +4869,10 @@ enterUsername = "أدخل اسم المستخدم"
|
||||
failedToSignIn = "فشل تسجيل الدخول بواسطة {{provider}}: {{message}}"
|
||||
forgotPassword = "هل نسيت كلمة المرور؟"
|
||||
loggingIn = "جارٍ تسجيل الدخول..."
|
||||
logIn = "تسجيل الدخول"
|
||||
login = "تسجيل الدخول"
|
||||
magicLinkSent = "تم إرسال الرابط السحري إلى {{email}}! تفقد بريدك واضغط الرابط لتسجيل الدخول."
|
||||
mfaCode = "رمز المصادقة"
|
||||
mfaRequired = "رمز المصادقة ذات العاملين مطلوب"
|
||||
noAccount = "ليس لديك حساب؟"
|
||||
or = "أو"
|
||||
password = "كلمة المرور"
|
||||
passwordChangedSuccess = "تم تغيير كلمة المرور بنجاح! يرجى تسجيل الدخول بكلمة المرور الجديدة."
|
||||
|
||||
@@ -4869,12 +4869,10 @@ enterUsername = "İstifadəçi adını daxil edin"
|
||||
failedToSignIn = "{{provider}} ilə daxil olmaq alınmadı: {{message}}"
|
||||
forgotPassword = "Parolunuzu unutmusunuz?"
|
||||
loggingIn = "Daxil olunur..."
|
||||
logIn = "Daxil ol"
|
||||
login = "Daxil ol"
|
||||
magicLinkSent = "Sehrli link {{email}} ünvanına göndərildi! Zəhmət olmasa e-poçtunuzu yoxlayın və daxil olmaq üçün linkə klikləyin."
|
||||
mfaCode = "Doğrulama kodu"
|
||||
mfaRequired = "İki faktorlu doğrulama kodu tələb olunur"
|
||||
noAccount = "Hesabınız yoxdur?"
|
||||
or = "Və ya"
|
||||
password = "Parol"
|
||||
passwordChangedSuccess = "Parol uğurla dəyişdirildi! Zəhmət olmasa yeni parolunuzla daxil olun."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user