diff --git a/.github/workflows/ci-browser-security.yml b/.github/workflows/ci-browser-security.yml index 054da3054..91ea3a705 100644 --- a/.github/workflows/ci-browser-security.yml +++ b/.github/workflows/ci-browser-security.yml @@ -55,6 +55,10 @@ jobs: - name: Install Apache and packaging tools run: | + # The hosted runner image ships Google's chrome-stable apt source, + # which these jobs never install from. Remove it before the first + # apt refresh so a mid-sync Google mirror cannot break the job. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update -q sudo apt-get install -y apache2 libapache2-mod-php jq rsync unzip zip sudo a2enmod rewrite headers env @@ -160,9 +164,49 @@ jobs: allow_issue_writing: false artifact_name: zap-baseline-${{ github.run_id }} + - name: Test narrow ZAP bibliographic PII allowlist + run: bash tests/zap-pii-filter.test.sh + - name: Fail on medium or high ZAP alerts run: | - test -s report_json.json || { echo "ZAP JSON report is missing"; exit 1; } + set -euo pipefail + identifiers_file="$RUNNER_TEMP/zap-catalogue-identifiers.json" + public_urls_file="$RUNNER_TEMP/zap-public-catalogue-urls.json" + public_examples_file="$RUNNER_TEMP/zap-public-example-identifiers.json" + + MYSQL_PWD="$E2E_DB_PASS" mysql \ + -h "$E2E_DB_HOST" -P "$E2E_DB_PORT" -u "$E2E_DB_USER" \ + -N -B "$E2E_DB_NAME" -e ' + SELECT identifier + FROM ( + SELECT isbn13 AS identifier FROM libri WHERE deleted_at IS NULL + UNION + SELECT ean AS identifier FROM libri WHERE deleted_at IS NULL + ) AS catalogue_identifiers + WHERE identifier REGEXP "^[0-9]{13}$" + ORDER BY identifier + ' | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' > "$identifiers_file" + + # The single-quoted PHP program is intentional; its $variables must + # reach PHP literally rather than expand in the runner shell. + # shellcheck disable=SC2016 + curl -fsS http://localhost:8081/sitemap.xml \ + | php -r ' + $xml = simplexml_load_string(stream_get_contents(STDIN), SimpleXMLElement::class, LIBXML_NONET); + if ($xml === false) { fwrite(STDERR, "Invalid sitemap XML\n"); exit(2); } + $urls = []; + foreach ($xml->url as $entry) { $urls[] = (string) $entry->loc; } + echo json_encode(array_values(array_unique($urls)), JSON_UNESCAPED_SLASHES), PHP_EOL; + ' > "$public_urls_file" + + # Translation dictionaries are intentionally inlined client-side. + # Extract only canonical, explicitly named ISBN example keys. The + # checked-in jq program requires a full 978/979 match and a valid + # ISBN-13 checksum, so unrelated numbers and longer substrings cannot + # become origin-wide exceptions. + jq -f scripts/ci-zap-public-isbn-examples.jq \ + locale/it_IT.json > "$public_examples_file" + # OWASP ZAP rule 10062 (PII Disclosure) is allowlisted NARROWLY, never # globally. A book catalogue renders ISBN/EAN-13 identifiers on its # bibliographic pages, and a 13-digit code inherently collides with the @@ -171,15 +215,15 @@ jobs: # 413167 is a Visa range). An alert is ignored ONLY when EVERY instance # is a 13-digit number on a bibliographic route — a real card leak # (15/16 digits) or a 13-digit value on any other page still fails the - # build. Real secret/PII exposure is also covered by the secret-scanning, - # Semgrep and CodeQL jobs; every other medium/high alert stays blocking. - FILTER='def is_isbn_fp: (.pluginid // "") == "10062" and ([ .instances[]? | ((.evidence // "") | test("^[0-9]{13}$")) and ((.uri // "") | test("/(auteur|author|autor|autore|book|buch|catalog|catalogo|catalogue|editore|editori|genere|genre|katalog|libro|libri|livre|publisher|verlag)"; "i")) ] | (length > 0 and all)); [ .site[]?.alerts[]? | select((.riskcode | tonumber) >= 2) | select(is_isbn_fp | not) ]' - blocking=$(jq "$FILTER | length" report_json.json) - if [ "$blocking" -gt 0 ]; then - jq -r "$FILTER"' | .[] | "[" + (.riskdesc // "") + "] " + (.alert // "") + ": " + (.desc // "")' report_json.json - exit 1 - fi - echo "ZAP found no blocking medium/high passive-scan alerts (allowlisted: 13-digit ISBN/EAN codes on bibliographic pages)" + # build. The checked-in filter also requires GET with empty param/attack, + # an exact registered route (canonical URLs come from the live sitemap) + # when evidence is a live catalogue identifier. The only route-wide + # exception is the exact public example strings shipped in the inlined + # translation dictionaries. Real secret/PII exposure is also covered + # by secret scanning, Semgrep and CodeQL; every other medium/high alert + # stays blocking. + bash scripts/ci-check-zap-report.sh \ + report_json.json "$identifiers_file" "$public_urls_file" "$public_examples_file" - name: Upload browser and server diagnostics if: always() diff --git a/.github/workflows/ci-deep-regression.yml b/.github/workflows/ci-deep-regression.yml index 3df25e097..157dad97f 100644 --- a/.github/workflows/ci-deep-regression.yml +++ b/.github/workflows/ci-deep-regression.yml @@ -67,6 +67,9 @@ jobs: - name: Install Apache, PHP module, MySQL client and mail transport run: | + # This job never installs Google Chrome. Remove its hosted-runner + # apt source before the first refresh so mirror syncs cannot break CI. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update -q sudo apt-get install -y apache2 libapache2-mod-php default-mysql-client msmtp-mta sudo a2enmod rewrite headers env @@ -279,6 +282,9 @@ jobs: persist-credentials: false - name: Install Apache and PHP module run: | + # This job never installs Google Chrome. Remove its hosted-runner + # apt source before the first refresh so mirror syncs cannot break CI. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update -q sudo apt-get install -y apache2 libapache2-mod-php default-mysql-client sudo a2enmod rewrite headers env diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 6fedf9970..14017c8b6 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -55,6 +55,9 @@ jobs: # supports PHP >= 8.1, so the Ubuntu mod_php package is sufficient here. - name: Install Apache2 + mod_php run: | + # This job never installs Google Chrome. Remove its hosted-runner + # apt source before the first refresh so mirror syncs cannot break CI. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update -q sudo apt-get install -y apache2 libapache2-mod-php sudo a2enmod rewrite headers env diff --git a/.github/workflows/ci-upgrade-smoke.yml b/.github/workflows/ci-upgrade-smoke.yml index 080987e35..1d0427f35 100644 --- a/.github/workflows/ci-upgrade-smoke.yml +++ b/.github/workflows/ci-upgrade-smoke.yml @@ -26,6 +26,7 @@ on: permissions: contents: read + attestations: read # Let `gh attestation verify` check the release archive's provenance. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -68,45 +69,73 @@ jobs: - name: Wait for MySQL run: until mysqladmin ping -h"127.0.0.1" --silent; do sleep 1; done - # ── Step 1: Resolve latest stable release ───────────────────────────── - - name: Resolve latest release + # ── Step 1: Resolve the newest stable baseline older than target ─────── + - name: Resolve upgrade baseline id: release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | - if gh api \ + TARGET_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' version.json) + gh api --paginate --slurp \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "repos/${GH_REPO}/releases/latest" >/tmp/release.json 2>/tmp/release.err; then - : - elif grep -q 'HTTP 404' /tmp/release.err; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "No releases found — skipping upgrade path (fresh install test only)" + "repos/${GH_REPO}/releases?per_page=100" >/tmp/releases.json + + TARGET_VERSION="$TARGET_VERSION" python3 <<'PY' >/tmp/release.json + import json + import os + import re + + target = os.environ['TARGET_VERSION'] + target_match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?', target) + if target_match is None: + raise SystemExit(f'Unsupported target version: {target}') + target_key = tuple(map(int, target_match.groups())) + + pages = json.load(open('/tmp/releases.json', encoding='utf-8')) + candidates = [] + for release in (item for page in pages for item in page): + match = re.fullmatch(r'v(\d+)\.(\d+)\.(\d+)', release.get('tag_name', '')) + if release.get('draft') or release.get('prerelease') or match is None: + continue + key = tuple(map(int, match.groups())) + if key < target_key: + candidates.append((key, release)) + + print(json.dumps(max(candidates, key=lambda item: item[0])[1] if candidates else {})) + PY + + TAG=$(jq -r '.tag_name // empty' /tmp/release.json) + if [ -z "$TAG" ]; then + { + echo "skip=true" + echo "target_version=$TARGET_VERSION" + } >> "$GITHUB_OUTPUT" + echo "No stable release older than $TARGET_VERSION — running fresh-install checks only" exit 0 - else - cat /tmp/release.err - echo "GitHub release API failed — aborting" - exit 1 fi - TAG=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); print(d.get('tag_name',''))") - ASSET_ID=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip')]; print(a[0]['id'] if len(a) == 1 else '')") - ASSET_NAME=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip')]; print(a[0]['name'] if len(a) == 1 else '')") - CHECKSUM_ID=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip.sha256')]; print(a[0]['id'] if len(a) == 1 else '')") - CHECKSUM_NAME=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip.sha256')]; print(a[0]['name'] if len(a) == 1 else '')") + + BASELINE_VERSION=${TAG#v} + ASSET_ID=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip")))] | if length == 1 then .[0].id else empty end' /tmp/release.json) + ASSET_NAME=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip")))] | if length == 1 then .[0].name else empty end' /tmp/release.json) + CHECKSUM_ID=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip.sha256")))] | if length == 1 then .[0].id else empty end' /tmp/release.json) + CHECKSUM_NAME=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip.sha256")))] | if length == 1 then .[0].name else empty end' /tmp/release.json) if [ -z "$TAG" ] || [ -z "$ASSET_ID" ] || [ -z "$ASSET_NAME" ] || [ -z "$CHECKSUM_ID" ] || [ -z "$CHECKSUM_NAME" ]; then - echo "Latest release must provide exactly one ZIP and one ZIP.sha256 asset" + echo "Baseline release must provide exactly one ZIP and one ZIP.sha256 asset" exit 1 fi { echo "tag=$TAG" + echo "baseline_version=$BASELINE_VERSION" + echo "target_version=$TARGET_VERSION" echo "asset_id=$ASSET_ID" echo "asset_name=$ASSET_NAME" echo "checksum_id=$CHECKSUM_ID" echo "checksum_name=$CHECKSUM_NAME" echo "skip=false" } >> "$GITHUB_OUTPUT" - echo "Latest release: $TAG ($ASSET_NAME; checksum verified before use)" + echo "Upgrade range: $BASELINE_VERSION → $TARGET_VERSION ($ASSET_NAME; checksum verified before use)" # ── Step 2: Install released schema ─────────────────────────────────── - name: Install released schema (baseline) @@ -115,6 +144,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + BASELINE_VERSION: ${{ steps.release.outputs.baseline_version }} ASSET_ID: ${{ steps.release.outputs.asset_id }} ARCHIVE_NAME: ${{ steps.release.outputs.asset_name }} CHECKSUM_ID: ${{ steps.release.outputs.checksum_id }} @@ -129,106 +159,171 @@ jobs: "repos/${GH_REPO}/releases/assets/${CHECKSUM_ID}" \ > "${RELEASE_DIR}/${CHECKSUM_NAME}" (cd "$RELEASE_DIR" && sha256sum -c "$CHECKSUM_NAME") + # The pre-immutability v0.7.63 release can technically have both its + # ZIP and sidecar replaced. Its GitHub provenance attestation pins the + # archive digest and source repository independently of those assets. + gh attestation verify "${RELEASE_DIR}/${ARCHIVE_NAME}" --repo "$GH_REPO" + ARCHIVE_PATH="${RELEASE_DIR}/${ARCHIVE_NAME}" EXPECTED_ROOT="pinakes-${RELEASE_TAG}" python3 <<'PY' + import os + import stat + import zipfile + from pathlib import PurePosixPath + + archive = os.environ['ARCHIVE_PATH'] + expected_root = os.environ['EXPECTED_ROOT'] + with zipfile.ZipFile(archive) as package: + entries = package.infolist() + if not entries: + raise SystemExit('Baseline release ZIP is empty') + for entry in entries: + name = entry.filename + path = PurePosixPath(name) + mode = (entry.external_attr >> 16) & 0o170000 + if ('\\' in name or path.is_absolute() or '..' in path.parts + or not path.parts or path.parts[0] != expected_root + or mode == stat.S_IFLNK): + raise SystemExit(f'Unsafe baseline ZIP entry: {name!r}') + PY mkdir "${RELEASE_DIR}/extracted" unzip -q "${RELEASE_DIR}/${ARCHIVE_NAME}" -d "${RELEASE_DIR}/extracted" - SCHEMA=$(find "${RELEASE_DIR}/extracted" -name "schema.sql" -path "*/database/*" | head -1) - if [ -z "$SCHEMA" ]; then - echo "schema.sql not found in release ZIP — aborting" + BASELINE_APP="${RELEASE_DIR}/extracted/pinakes-${RELEASE_TAG}" + SCHEMA="${BASELINE_APP}/installer/database/schema.sql" + if [ ! -f "$SCHEMA" ] || [ ! -f "${BASELINE_APP}/vendor/autoload.php" ]; then + echo "Expected application root pinakes-${RELEASE_TAG} not found in release ZIP — aborting" exit 1 fi - echo "release_dir=${RELEASE_DIR}/extracted" >> "$GITHUB_OUTPUT" + EMBEDDED_VERSION=$(jq -er '.version' "${BASELINE_APP}/version.json") + [ "$EMBEDDED_VERSION" = "$BASELINE_VERSION" ] || { + echo "Baseline tag/archive mismatch: $RELEASE_TAG contains version $EMBEDDED_VERSION" + exit 1 + } + echo "release_dir=${BASELINE_APP}" >> "$GITHUB_OUTPUT" echo "Using schema: $SCHEMA (from $RELEASE_TAG)" mysql -h 127.0.0.1 -u root -proot pinakes_upgrade < "$SCHEMA" - echo "✓ Base schema installed from $RELEASE_TAG" - - # ── Step 3: Apply released migrations (idempotent baseline) ─────────── - # Released migrations run on top of the released schema.sql which is - # already fully migrated up to that release. Every error here is by - # definition idempotent: duplicate columns/keys, FKs that already exist, - # RENAME/CHANGE on columns that were already renamed in schema.sql, etc. - # The extended idempotent filter covers cases that older migration files - # (pre-guard era) produce when re-run on an already-migrated schema. - - name: Apply released migrations - if: steps.release.outputs.skip == 'false' - env: - RELEASE_DIR: ${{ steps.baseline.outputs.release_dir }} - run: | - MIGRATIONS=$(find "$RELEASE_DIR" -type d -name "migrations" | head -1) - if [ -z "$MIGRATIONS" ]; then - echo "No migrations dir in release ZIP — skipping" - exit 0 - fi - FAILED=0 - mapfile -t RELEASE_MIGRATIONS < <(find "$MIGRATIONS" -maxdepth 1 -type f -name 'migrate_*.sql' | sort -V) - for sql in "${RELEASE_MIGRATIONS[@]}"; do - echo "→ $(basename "$sql")" - OUTPUT=$(mysql --force -h 127.0.0.1 -u root -proot pinakes_upgrade < "$sql" 2>&1) || true - while IFS= read -r line; do - if echo "$line" | grep -qiE '^ERROR'; then - if echo "$line" | grep -qiE \ - 'duplicate (column name|key|index)|table .* already exists|check constraint .* already exists|can.t drop|duplicate foreign key constraint name|unknown column'; then - echo " ↳ idempotent: $line" - else - echo " ✗ $line" - FAILED=1 - fi - fi - done <<< "$OUTPUT" - done - [ "$FAILED" -eq 0 ] || exit 1 - echo "✓ Released migrations applied" - # ── Step 4: Fresh-install fallback (no prior release) ───────────────── + # schema.sql intentionally contains structure only. A real installed + # baseline has already booted PluginManager, which creates the bundled + # registry rows and plugin-owned schemas. Reproduce that state with the + # code and manifests from the downloaded release, before any overlay. + BASELINE_APP="$BASELINE_APP" php <<'PHP' + connect_errno !== 0) { + fwrite(STDERR, "Database connection failed: {$db->connect_error}\n"); + exit(1); + } + $db->set_charset('utf8mb4'); + (new App\Support\PluginManager($db, new App\Support\HookManager($db))) + ->autoRegisterBundledPlugins(); + $expected = App\Support\BundledPlugins::LIST; + sort($expected, SORT_STRING); + $actual = []; + $result = $db->query('SELECT name FROM plugins ORDER BY name'); + while ($result instanceof mysqli_result && ($row = $result->fetch_assoc()) !== null) { + $actual[] = (string) $row['name']; + } + if ($actual !== $expected) { + fwrite(STDERR, 'Baseline plugin registry mismatch: ' . json_encode($actual) + . ', expected ' . json_encode($expected) . "\n"); + exit(1); + } + PHP + echo "✓ Base schema and bundled plugin registry installed from $RELEASE_TAG" + + # ── Step 3: Fresh-install fallback (no prior release) ───────────────── - name: Install base schema (fresh-install fallback) if: steps.release.outputs.skip == 'true' run: | mysql -h 127.0.0.1 -u root -proot pinakes_upgrade < installer/database/schema.sql - echo "✓ Base schema installed from current branch (no prior release)" + php <<'PHP' + &1) || true - while IFS= read -r line; do - if echo "$line" | grep -qiE '^ERROR'; then - if echo "$line" | grep -qiE 'duplicate (column name|key|index)|table .* already exists|check constraint .* already exists|can.t drop|duplicate foreign key constraint name'; then - echo " ↳ idempotent: $line" - else - echo " ✗ $line" - FAILED=1 - fi - fi - done <<< "$OUTPUT" - done - [ "$FAILED" -eq 0 ] || exit 1 - echo "✓ Current branch migrations applied" + // The CLI SAPI does not define STDIN/STDOUT/STDERR when the script is + // read from standard input (as these heredoc scripts are) - define them. + if (!defined('STDOUT')) { define('STDOUT', fopen('php://stdout', 'wb')); } + if (!defined('STDERR')) { define('STDERR', fopen('php://stderr', 'wb')); } - # ── Step 6: Exercise runtime-only migration work ───────────────── - - name: Run contributor backfill through the application updater + require getcwd() . '/vendor/autoload.php'; + mysqli_report(MYSQLI_REPORT_OFF); + $db = new mysqli('127.0.0.1', 'root', 'root', 'pinakes_upgrade', 3306); + if ($db->connect_errno !== 0) { + fwrite(STDERR, "Database connection failed: {$db->connect_error}\n"); + exit(1); + } + $db->set_charset('utf8mb4'); + (new App\Support\PluginManager($db, new App\Support\HookManager($db))) + ->autoRegisterBundledPlugins(); + PHP + echo "✓ Base schema and bundled plugin registry installed from current branch" + + # ── Step 4: Upgrade through the production migration runner ─────────── + - name: Upgrade baseline through Updater and prove idempotency + if: steps.release.outputs.skip == 'false' + env: + BASELINE_APP: ${{ steps.baseline.outputs.release_dir }} + BASELINE_VERSION: ${{ steps.release.outputs.baseline_version }} + TARGET_VERSION: ${{ steps.release.outputs.target_version }} run: | + PICKUP_COLUMNS_BEFORE=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name IN ('pickup_notification_sent','pickup_notification_claim_token','pickup_notification_last_attempt_at')") + # The single-quoted payload below is PHP, not shell. + # shellcheck disable=SC2016 + EXPECT_0764=$(BASELINE_VERSION="$BASELINE_VERSION" TARGET_VERSION="$TARGET_VERSION" php -r ' + $from = (string) getenv("BASELINE_VERSION"); + $to = (string) getenv("TARGET_VERSION"); + echo version_compare($from, "0.7.64", "<") && version_compare($to, "0.7.64", ">=") ? "yes" : "no"; + ') + if [ "$EXPECT_0764" = "yes" ] && [ "$PICKUP_COLUMNS_BEFORE" -ne 0 ]; then + echo "Baseline $BASELINE_VERSION unexpectedly already has 0.7.64 pickup notification columns" + exit 1 + fi + mysql -h 127.0.0.1 -u root -proot pinakes_upgrade <<'SQL' DELETE FROM system_settings WHERE category = 'migrations' AND setting_key = 'contributors_backfilled'; - DELETE FROM libri WHERE titolo = 'ZZ upgrade smoke contributor backfill'; + INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES ('UPGRADE-SMOKE-0764', 'Upgrade', 'Smoke', 'upgrade-smoke-0764@example.invalid', 'not-a-real-hash', 'attivo', 'standard', 1); + SET @upgrade_smoke_user_id = LAST_INSERT_ID(); INSERT INTO libri (titolo, illustratore, curatore) VALUES ( 'ZZ upgrade smoke contributor backfill', 'ZZ Upgrade Illustrator One; ZZ Upgrade Illustrator Two', 'García Márquez, Gabriel José' ); + SET @upgrade_smoke_book_id = LAST_INSERT_ID(); + INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo) + VALUES (@upgrade_smoke_book_id, @upgrade_smoke_user_id, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL 7 DAY), 'da_ritirare', 1); + INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, attivo) + VALUES (@upgrade_smoke_book_id, @upgrade_smoke_user_id, DATE_SUB(CURRENT_DATE, INTERVAL 14 DAY), DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY), DATE_SUB(CURRENT_DATE, INTERVAL 8 DAY), 'restituito', 0); SQL - TARGET_VERSION=$(python3 -c "import json; print(json.load(open('version.json')).get('version', ''))") - [ -n "$TARGET_VERSION" ] || { echo "Target version missing from version.json"; exit 1; } - TARGET_VERSION="$TARGET_VERSION" php <<'PHP' + BASELINE_VERSION="$BASELINE_VERSION" TARGET_VERSION="$TARGET_VERSION" EXPECT_0764="$EXPECT_0764" php <<'PHP' connect_errno !== 0) { @@ -236,14 +331,166 @@ jobs: exit(1); } $db->set_charset('utf8mb4'); + $from = (string) getenv('BASELINE_VERSION'); $target = (string) getenv('TARGET_VERSION'); - $result = (new App\Support\Updater($db))->runMigrations($target, $target); - if (!$result['success']) { - fwrite(STDERR, 'Updater failed: ' . ($result['error'] ?? 'unknown error') . "\n"); + + $baselineVersion = json_decode( + (string) file_get_contents($baselineRoot . '/version.json'), + true, + 512, + JSON_THROW_ON_ERROR + )['version'] ?? ''; + if ($baselineVersion !== $from) { + fwrite(STDERR, "Loaded baseline version {$baselineVersion}, expected {$from}\n"); exit(1); } + + // Force the starting release's Updater class and instantiate it BEFORE + // overlaying target files. installUpdate() behaves the same way: the old + // object remains in memory after it copies the new package, then that + // old runner executes the newly copied migration files. + $updaterReflection = new ReflectionClass(App\Support\Updater::class); + $loadedUpdaterFile = realpath((string) $updaterReflection->getFileName()); + $expectedUpdaterFile = realpath($baselineRoot . '/app/Support/Updater.php'); + if ($loadedUpdaterFile === false || $loadedUpdaterFile !== $expectedUpdaterFile) { + fwrite(STDERR, "Updater was not loaded from the downloaded baseline\n"); + exit(1); + } + $updater = new App\Support\Updater($db); + + /** @param non-empty-string $source @param non-empty-string $destination */ + $copyTree = static function (string $source, string $destination): void { + if (!is_dir($destination) && !mkdir($destination, 0775, true) && !is_dir($destination)) { + throw new RuntimeException("Could not create overlay directory: {$destination}"); + } + $prefixLength = strlen(rtrim($source, DIRECTORY_SEPARATOR)) + 1; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { + if ($item->isLink()) { + throw new RuntimeException("Target source overlay contains a symlink: {$item->getPathname()}"); + } + $relative = substr($item->getPathname(), $prefixLength); + $targetPath = $destination . DIRECTORY_SEPARATOR . $relative; + if ($item->isDir()) { + if (!is_dir($targetPath) && !mkdir($targetPath, 0775, true) && !is_dir($targetPath)) { + throw new RuntimeException("Could not create overlay directory: {$targetPath}"); + } + } elseif (!copy($item->getPathname(), $targetPath)) { + throw new RuntimeException("Could not overlay target file: {$relative}"); + } + } + }; + + $copyTree($currentRoot . '/app', $baselineRoot . '/app'); + $copyTree($currentRoot . '/storage/plugins', $baselineRoot . '/storage/plugins'); + $copyTree( + $currentRoot . '/installer/database/migrations', + $baselineRoot . '/installer/database/migrations' + ); + foreach (['installer/database/triggers.sql', 'version.json'] as $relativeFile) { + if (!copy($currentRoot . '/' . $relativeFile, $baselineRoot . '/' . $relativeFile)) { + throw new RuntimeException("Could not overlay target file: {$relativeFile}"); + } + } + + // Production synchronizes bundled manifests after copying their target + // files and before database migrations. The class was not loaded by the + // baseline Updater constructor, so autoload resolves this implementation + // from the newly overlaid target tree just as it does in installUpdate(). + (new App\Support\PluginManager($db, new App\Support\HookManager($db))) + ->autoRegisterBundledPlugins(); + + $shouldRun = static function (string $migration, string $rangeFrom, string $rangeTo): bool { + if (method_exists(App\Support\Updater::class, 'shouldRunMigration')) { + return App\Support\Updater::shouldRunMigration($migration, $rangeFrom, $rangeTo); + } + return version_compare($migration, $rangeFrom, '>') + && version_compare($migration, $rangeTo, '<='); + }; + foreach ([ + ['0.7.63', '0.7.64-rc.1', true], + ['0.7.63', '0.7.64', true], + ['0.7.63', '0.7.65-rc.1', true], + ] as [$matrixFrom, $matrixTarget, $matrixExpected]) { + if ($shouldRun('0.7.64-rc.1', $matrixFrom, $matrixTarget) !== $matrixExpected) { + fwrite(STDERR, "Migration range predicate failed for {$matrixFrom} → {$matrixTarget}\n"); + exit(1); + } + } + + $expected = array_values(array_filter( + array_map('basename', glob($baselineRoot . '/installer/database/migrations/migrate_*.sql') ?: []), + static function (string $filename) use ($from, $target, $shouldRun): bool { + if (preg_match('/^migrate_(.+)\.sql$/', $filename, $matches) !== 1) { + return false; + } + return $shouldRun($matches[1], $from, $target); + } + )); + usort($expected, static function (string $a, string $b): int { + preg_match('/^migrate_(.+)\.sql$/', $a, $aMatch); + preg_match('/^migrate_(.+)\.sql$/', $b, $bMatch); + return version_compare($aMatch[1], $bMatch[1]); + }); + + $first = $updater->runMigrations($from, $target); + if (!$first['success']) { + fwrite(STDERR, 'Updater failed: ' . ($first['error'] ?? 'unknown error') . "\n"); + exit(1); + } + if ($first['executed'] !== $expected) { + fwrite(STDERR, 'Updater executed ' . json_encode($first['executed']) + . ', expected ' . json_encode($expected) . "\n"); + exit(1); + } + if (getenv('EXPECT_0764') === 'yes' && !in_array('migrate_0.7.64-rc.1.sql', $first['executed'], true)) { + fwrite(STDERR, "Updater did not select migrate_0.7.64-rc.1.sql for the tested version range\n"); + exit(1); + } + + $second = $updater->runMigrations($from, $target); + if (!$second['success'] || $second['executed'] !== []) { + fwrite(STDERR, 'Second Updater pass was not an idempotent no-op: ' + . json_encode($second) . "\n"); + exit(1); + } + fwrite(STDOUT, "✓ Loaded baseline Updater from {$loadedUpdaterFile}, then overlaid target files\n"); PHP + if [ "$EXPECT_0764" = "yes" ]; then + COLUMN_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT CONCAT(DATA_TYPE, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL')) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_sent'") + [ "$COLUMN_META" = "tinyint|YES|0" ] || { echo "Unexpected pickup_notification_sent definition: $COLUMN_META"; exit 1; } + TOKEN_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT CONCAT(DATA_TYPE, '|', CHARACTER_MAXIMUM_LENGTH, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL'), '|', COLLATION_NAME) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_claim_token'") + [ "$TOKEN_META" = "char|32|YES|NULL|ascii_bin" ] || { echo "Unexpected pickup_notification_claim_token definition: $TOKEN_META"; exit 1; } + ATTEMPT_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT CONCAT(DATA_TYPE, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL')) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_last_attempt_at'") + [ "$ATTEMPT_META" = "datetime|YES|NULL" ] || { echo "Unexpected pickup_notification_last_attempt_at definition: $ATTEMPT_META"; exit 1; } + READY_BACKFILL=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT pickup_notification_sent FROM prestiti p JOIN utenti u ON u.id=p.utente_id WHERE u.codice_tessera='UPGRADE-SMOKE-0764' AND p.stato='da_ritirare'") + [ "$READY_BACKFILL" = "1" ] || { echo "Historical ready-for-pickup loan was not marked as already notified"; exit 1; } + OTHER_BACKFILL=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT pickup_notification_sent FROM prestiti p JOIN utenti u ON u.id=p.utente_id WHERE u.codice_tessera='UPGRADE-SMOKE-0764' AND p.stato='restituito'") + [ "$OTHER_BACKFILL" = "1" ] || { echo "Historical non-ready loan did not receive the legacy-safe sent marker"; exit 1; } + NEW_DEFAULT=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e " + INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, attivo) + SELECT l.id, u.id, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL 7 DAY), CURRENT_DATE, 'restituito', 0 + FROM libri l + JOIN utenti u ON u.codice_tessera='UPGRADE-SMOKE-0764' + WHERE l.titolo='ZZ upgrade smoke contributor backfill' + LIMIT 1; + SELECT CONCAT(pickup_notification_sent, '|', COALESCE(pickup_notification_claim_token, 'NULL'), '|', COALESCE(pickup_notification_last_attempt_at, 'NULL')) FROM prestiti WHERE id=LAST_INSERT_ID();") + [ "$NEW_DEFAULT" = "0|NULL|NULL" ] || { echo "New post-upgrade loan received unexpected pickup claim defaults: $NEW_DEFAULT"; exit 1; } + MIGRATION_RECORD=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT CONCAT(COUNT(*), '|', COALESCE(MAX(filename), '')) FROM migrations WHERE version='0.7.64-rc.1'") + [ "$MIGRATION_RECORD" = "1|migrate_0.7.64-rc.1.sql" ] || { echo "Unexpected 0.7.64 migration record: $MIGRATION_RECORD"; exit 1; } + echo "✓ migrate_0.7.64-rc.1.sql selected by baseline Updater; legacy marker/default/record verified" + fi + BOOK_ID=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ "SELECT id FROM libri WHERE titolo='ZZ upgrade smoke contributor backfill' LIMIT 1") [ -n "$BOOK_ID" ] || { echo "Backfill fixture book missing"; exit 1; } @@ -264,9 +511,9 @@ jobs: [ "$CURATORS" -eq 1 ] || { echo "Expected 1 curator link, got $CURATORS"; exit 1; } [ "$CANONICAL_CURATOR" -eq 1 ] || { echo "SBN comma name was not preserved and normalized correctly"; exit 1; } [ "$PROVENANCE" -eq 3 ] || { echo "Expected 3 provenance rows, got $PROVENANCE"; exit 1; } - echo "✓ Runtime contributor backfill completed through Updater" + echo "✓ Upgrade ran through Updater and the second pass was a no-op" - # ── Step 7: Verify schema after upgrade ────────────────────────────── + # ── Step 5: Verify schema after upgrade ────────────────────────────── - name: Verify core tables present run: | TABLE_OUTPUT=$(php scripts/list-source-expectations.php tables) @@ -330,6 +577,9 @@ jobs: [ "$FAILED" -eq 0 ] || exit 1 - name: Verify key columns from recent migrations + env: + TARGET_VERSION: ${{ steps.release.outputs.target_version }} + BASELINE_SKIPPED: ${{ steps.release.outputs.skip }} run: | FAILED=0 @@ -359,21 +609,38 @@ jobs: check_col digital_assets md5_hash check_col digital_assets filesize check_col digital_assets filetype + # v0.7.64 — pickup notification claim/retry state. A fresh-install + # schema always reflects the checkout; an upgrade only expects this + # stable migration when the semver range actually includes 0.7.64. + EXPECT_0764=$(TARGET_VERSION="$TARGET_VERSION" php -r ' + echo version_compare((string) getenv("TARGET_VERSION"), "0.7.64", ">=") ? "yes" : "no"; + ') + if [ "$BASELINE_SKIPPED" = "true" ] || [ "$EXPECT_0764" = "yes" ]; then + check_col prestiti pickup_notification_sent + check_col prestiti pickup_notification_claim_token + check_col prestiti pickup_notification_last_attempt_at + else + echo " ↳ prestiti.pickup_notification_sent not expected before stable 0.7.64" + fi [ "$FAILED" -eq 0 ] || exit 1 - name: Verify plugin registrations run: | - EXPECTED="api-book-scraper archives bibframe-linked-data deezer dewey-editor digital-library discogs goodlib musicbrainz ncip-server oai-pmh-server open-library openurl-resolver resource-sync viaf-authority z39-server" + PLUGIN_OUTPUT=$(php scripts/list-source-expectations.php plugins) + mapfile -t EXPECTED_PLUGINS <<< "$PLUGIN_OUTPUT" + [ "${#EXPECTED_PLUGINS[@]}" -gt 0 ] || { echo "No bundled plugins derived from BundledPlugins::LIST"; exit 1; } FAILED=0 - for plugin in $EXPECTED; do - EXISTS=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ - "SELECT COUNT(*) FROM plugins WHERE name='$plugin'" 2>/dev/null || echo "0") - if [ "$EXISTS" -eq 0 ]; then - echo " ✗ Plugin not registered: $plugin" + for plugin in "${EXPECTED_PLUGINS[@]}"; do + [[ "$plugin" =~ ^[a-z0-9-]+$ ]] || { echo "Invalid bundled plugin slug: $plugin"; exit 1; } + EXPECTED_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "storage/plugins/${plugin}/plugin.json") + ROW=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \ + "SELECT CONCAT(COUNT(*), '|', COALESCE(MAX(version), '')) FROM plugins WHERE name='$plugin'" 2>/dev/null || echo "0|") + if [ "$ROW" != "1|$EXPECTED_VERSION" ]; then + echo " ✗ Plugin registry mismatch: $plugin (got $ROW, expected 1|$EXPECTED_VERSION)" FAILED=1 else - echo " ✓ $plugin" + echo " ✓ $plugin $EXPECTED_VERSION" fi done [ "$FAILED" -eq 0 ] || exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c31cc139a..e01652701 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,17 +12,15 @@ concurrency: cancel-in-progress: false jobs: - release: - name: Verify, attest and publish release + build: + name: Verify and build release (read-only) runs-on: ubuntu-latest timeout-minutes: 45 permissions: - contents: write # Create the GitHub Release and upload its assets. + contents: read # Checkout and inspect the tagged source; never publish from this job. checks: read # Require every protected-branch check before publishing an RC. statuses: read # Read legacy commit-status based required checks. pull-requests: read # Validate prereleases against the exact merge-ready PR head. - id-token: write # Sign the artifact provenance with GitHub OIDC. - attestations: write # Store the artifact provenance attestation. steps: - name: Checkout complete history without persisted credentials @@ -102,11 +100,6 @@ jobs: output-file: releases/pinakes.spdx.json upload-artifact: false - - name: Attest release provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: releases/*.zip - - name: Extract version changelog env: RELEASE_TAG: ${{ github.ref_name }} @@ -133,31 +126,203 @@ jobs: echo 'a GitHub artifact provenance attestation.' } > releases/GITHUB_RELEASE_BODY.md - - name: Publish verified GitHub Release + - name: Retain release evidence in Actions + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: release-evidence-${{ github.ref_name }} + path: | + releases/*.zip + releases/*.sha256 + releases/*.spdx.json + releases/RELEASE_NOTES-*.md + releases/GITHUB_RELEASE_BODY.md + if-no-files-found: error + retention-days: 90 + + publish: + name: Attest and publish verified release + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # Create the GitHub Release and upload its assets. + id-token: write # Sign the artifact provenance with GitHub OIDC. + attestations: write # Store the artifact provenance attestation. + + steps: + # The privileged job consumes only the artifact produced by the read-only + # build job. Composer/npm lifecycle scripts and tagged repository scripts + # therefore never execute with a contents-write or OIDC-capable token. + - name: Download verified release evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: release-evidence-${{ github.ref_name }} + path: releases + + - name: Require publisher tools + run: | + command -v gh >/dev/null + command -v jq >/dev/null + command -v sha256sum >/dev/null + + - name: Attest release provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: releases/*.zip + + # This workflow is the sole builder and publisher. create-release.sh only + # preflights/pushes the tag and then observes this run; it never creates, + # uploads, or replaces an asset. Keep the release as a draft until the + # server-side digests have been checked against the reproducible build. + - name: Create draft and upload verified assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ github.ref_name }} run: | + version=${TAG_NAME#v} + resolved_tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG_NAME}" --jq '.sha') + test "$resolved_tag_sha" = "$GITHUB_SHA" || { + echo "Release tag moved before draft creation: $resolved_tag_sha != $GITHUB_SHA" >&2 + exit 1 + } mapfile -t assets < <(find releases -maxdepth 1 -type f \ \( -name '*.zip' -o -name '*.zip.sha256' -o -name '*.spdx.json' -o -name 'RELEASE_NOTES-*.md' \) \ -print | sort) - test "${#assets[@]}" -ge 4 - release_flags=(--verify-tag --title "Pinakes $TAG_NAME" --notes-file releases/GITHUB_RELEASE_BODY.md) + expected_assets=$(printf '%s\n' \ + "RELEASE_NOTES-v${version}.md" \ + "pinakes-v${version}.zip" \ + "pinakes-v${version}.zip.sha256" \ + "pinakes.spdx.json") + actual_assets=$(printf '%s\n' "${assets[@]##*/}" | LC_ALL=C sort) + if [[ "$actual_assets" != "$expected_assets" ]]; then + printf 'Expected local assets:\n%s\nActual local assets:\n%s\n' "$expected_assets" "$actual_assets" >&2 + exit 1 + fi + release_flags=(--verify-tag --draft --title "Pinakes $TAG_NAME" --notes-file releases/GITHUB_RELEASE_BODY.md) if [[ "$TAG_NAME" == *-* ]]; then release_flags+=(--prerelease) + fi + gh release create "$TAG_NAME" "${assets[@]}" "${release_flags[@]}" --repo "$GITHUB_REPOSITORY" + resolved_tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG_NAME}" --jq '.sha') + test "$resolved_tag_sha" = "$GITHUB_SHA" || { + echo "Release tag moved during draft creation: $resolved_tag_sha != $GITHUB_SHA" >&2 + exit 1 + } + + - name: Verify uploaded assets and publish the draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: | + version=${TAG_NAME#v} + resolved_tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG_NAME}" --jq '.sha') + test "$resolved_tag_sha" = "$GITHUB_SHA" || { + echo "Release tag moved before verification: $resolved_tag_sha != $GITHUB_SHA" >&2 + exit 1 + } + release_id=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq ".[] | select(.tag_name == \"${TAG_NAME}\" and .draft == true) | .id" | head -n 1) + test -n "$release_id" || { + echo "Could not resolve the draft release for $TAG_NAME" >&2 + exit 1 + } + + expected_assets=$(printf '%s\n' \ + "RELEASE_NOTES-v${version}.md" \ + "pinakes-v${version}.zip" \ + "pinakes-v${version}.zip.sha256" \ + "pinakes.spdx.json") + local_sha=$(sha256sum "releases/pinakes-v${version}.zip" | cut -d' ' -f1) + sidecar_sha=$(awk 'NR == 1 { print $1 }' "releases/pinakes-v${version}.zip.sha256") + test "$local_sha" = "$sidecar_sha" || { + echo "Local ZIP and checksum sidecar disagree" >&2 + exit 1 + } + + assets_json='[]' + for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + assets_json=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100") + digests_ready=$(jq -r ' + if length == 4 and all(.[]; (.digest // "") | test("^sha256:[0-9a-fA-F]{64}$")) + then "yes" else "no" end + ' <<<"$assets_json") + if [[ "$digests_ready" == "yes" ]]; then + break + fi + echo "Asset digests not ready yet ($attempt/12)" + sleep 10 + done + + actual_assets=$(jq -r '.[].name' <<<"$assets_json" | LC_ALL=C sort) + if [[ "$actual_assets" != "$expected_assets" ]]; then + printf 'Expected assets:\n%s\nActual assets:\n%s\n' "$expected_assets" "$actual_assets" >&2 + exit 1 + fi + if ! jq -e ' + length == 4 + and all(.[]; + .state == "uploaded" + and .size > 0 + and (.digest | test("^sha256:[0-9a-fA-F]{64}$")) + and .uploader.login == "github-actions[bot]") + ' >/dev/null <<<"$assets_json"; then + echo "Every release asset must be complete, workflow-owned, and expose a SHA-256 digest" >&2 + exit 1 + fi + mapfile -t local_assets < <(find releases -maxdepth 1 -type f \ + \( -name '*.zip' -o -name '*.zip.sha256' -o -name '*.spdx.json' -o -name 'RELEASE_NOTES-*.md' \) \ + -print | sort) + for asset_path in "${local_assets[@]}"; do + asset_name=${asset_path##*/} + local_digest="sha256:$(sha256sum "$asset_path" | cut -d' ' -f1)" + remote_digest=$(jq -r --arg name "$asset_name" \ + '.[] | select(.name == $name) | .digest // empty' <<<"$assets_json") + test "$remote_digest" = "$local_digest" || { + echo "Remote digest mismatch for $asset_name" >&2 + exit 1 + } + done + + publish_flags=(--draft=false) + if [[ "$TAG_NAME" == *-* ]]; then + publish_flags+=(--prerelease) else - release_flags+=(--latest) + publish_flags+=(--latest) + fi + resolved_tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG_NAME}" --jq '.sha') + test "$resolved_tag_sha" = "$GITHUB_SHA" || { + echo "Release tag moved before publication: $resolved_tag_sha != $GITHUB_SHA" >&2 + exit 1 + } + gh release edit "$TAG_NAME" "${publish_flags[@]}" --repo "$GITHUB_REPOSITORY" + + # Digest checks are only a point-in-time guarantee unless GitHub locks + # the published release. create-release.sh checks the repository policy + # before pushing the tag; this postcondition also protects direct tag + # pushes and fails loudly if the setting was disabled mid-run. + published_release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}") + resolved_tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG_NAME}" --jq '.sha') + if [[ "$resolved_tag_sha" != "$GITHUB_SHA" ]]; then + echo "Immutable release tag resolves to $resolved_tag_sha, expected $GITHUB_SHA" >&2 + exit 1 + fi + if ! jq -e '.draft == false and .immutable == true' >/dev/null <<<"$published_release"; then + # The normal local entry point prevents this state before tag push. + # A direct tag push can bypass that preflight, so hide the mutable + # release again immediately rather than leave it installable. + gh release edit "$TAG_NAME" --draft=true --repo "$GITHUB_REPOSITORY" || true + echo "Published release $TAG_NAME was not immutable and was returned to draft" >&2 + exit 1 fi - gh release create "$TAG_NAME" "${assets[@]}" "${release_flags[@]}" - name: Trigger the Docker image rebuild (stable only, non-fatal) - # This workflow replaced create-release.sh, which fired this dispatch so - # pinakes-docker rebuilds and republishes the image on a stable release — + # The former local release producer fired this dispatch, so this sole + # release pipeline keeps pinakes-docker rebuilding on a stable release — # without it a stable published here never reaches Docker Hub/GHCR until # pinakes-docker's daily poller catches it. The default GITHUB_TOKEN cannot # dispatch to another repository, so this needs a PAT secret with dispatch - # (contents:write) access to fabiodalez-dev/pinakes-docker. Prereleases are - # skipped (matching create-release.sh); a missing token or a failed + # (contents:write) access to fabiodalez-dev/pinakes-docker. Prereleases + # are skipped; a missing token or a failed # dispatch is non-fatal because the daily poller is the backstop. if: ${{ !contains(github.ref_name, '-') }} env: @@ -176,15 +341,3 @@ jobs: else echo "pinakes-docker dispatch failed (non-fatal; the daily poller is the backstop)." >&2 fi - - - name: Retain release evidence in Actions - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: release-evidence-${{ github.ref_name }} - path: | - releases/*.zip - releases/*.sha256 - releases/*.spdx.json - releases/RELEASE_NOTES-*.md - if-no-files-found: error - retention-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6673b4e..944755f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,123 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.7.64] - 2026-08-21 + +Optional multiple physical copies of the same title per borrower (#238). + +### Features + +- **Multiple copies per borrower** (opt-in, Settings → Loans → "Più copie dello + stesso titolo", off by default): staff can lend more than one distinct + physical copy of the same title to the same borrower. Every loan stays bound + to its own copy, so no two open loans for a borrower can share a copy. + Pending requests, reservations and legacy copyless rows keep the historical + borrower/title uniqueness, and per-copy overlap guards, eligibility, capacity + and the max-active-loans limit are all unchanged (each copy counts as one + active loan). The desk "Salva e registra un'altra copia" flow keeps the + selected title and focuses the next copy scan for fast batch check-outs. + Existing installations are unaffected without a migration — the setting + resolves to off until it is enabled, and fresh installs seed it off in all + five shipped locales. + +### Fixed + +- **Circulation can no longer hand out a copy that is physically out** (#366 + residual): creation, approval, rescheduling, renewal, reassignment and the DB + triggers all treat an `in_corso` loan whose due date has passed but that the + maintenance sweep has not yet flipped to `in_ritardo` as an open-ended + commitment (same rule as `CapacityService`). The pre-assigned copy is + validated against the loan's book, and a request whose whole window is already + past is rejected instead of becoming an unpickable `da_ritirare`. + `confirmPickup` re-checks the loan rows holding the copy (not just + `copie.stato`) before issuing. Approval and the date-aware allocator still + accept a copy that is physically out as long as the requested window does not + overlap its open loan — the per-copy overlap check is the authority — so a + future-dated request is not needlessly blocked when the only copy is on loan. +- **Backdating a loan's due date reports a clear error**: actively moving an + active loan's due date into the past used to reach the per-copy overlap + trigger and surface an opaque `loan_update_failed`; the edit flow now rejects + it up front with the same `expired_window` message the create/approve flows + use, while still allowing edits to an already-overdue loan whose due date is + left unchanged. +- **Queue promotion re-checks borrower eligibility**: a user suspended (or + with an expired card) while waiting is skipped — their reservation stays + active in the queue — instead of being promoted to a pending loan that + approval then refuses, burning their position and pinning the copy. The scan + is not capped at the first 25 rows, so an eligible reader farther down the + FIFO can still receive a free copy. +- **Reservations on books without copy rows are refused up front**: the queue + can only convert physical `copie` rows, so a legacy book with none produced + reservations that silently never converted until they expired. +- **Reassigning a promoted loan keeps queue semantics** (multiplicity ON): a + row with `origine='prenotazione'` still in `prenotato`/`da_ritirare` is + treated as a queue commitment when reassigned to another borrower, so a user + can never end up with a physical loan plus a promoted queue position for the + same title. +- **The desk create form is double-submit safe**: submit buttons disable on + first submit and every rendered form carries a one-time server token. A + replayed POST therefore cannot register a second real loan on another copy, + including retries caused by latency or double-clicks. +- **A freed copy now serves every compatible hold**: `reassignOnReturn` keeps + assigning the returned copy until no blocked FIFO candidate can take it + (holds with disjoint windows previously waited for the next maintenance + sweep), and holds whose window is entirely past are no longer eligible. +- **Pickup-ready emails are claim-and-retry**: a new + `prestiti.pickup_notification_sent` flag (seeded in the schema and added by + an idempotent upgrade migration) makes the "ready for pickup" email + idempotent. Token-owned claims recover after a worker crash, failures are + retried fairly by the hourly notification cron, and existing rows are + initialized as already handled without firing legacy circulation triggers. + Every future transition to ready resets the flag; reassigning a ready loan + resets the recipient-specific claim as well. +- **Borrowers without an email no longer wedge the notification crons**: + expiry warnings and overdue notices for them keep their claim (no more + endless SMTP retry churn) and still produce the in-app/admin notifications, + which previously never fired for email-less borrowers. +- **Cron process locks no longer unlink after unlock** (the race + `scripts/maintenance.php` already documented), both cron entrypoints refuse + non-CLI execution, and `runAll()` refreshes the cross-session cooldown + marker so an admin login right after the cron no longer re-runs the whole + maintenance synchronously. +- **NCIP**: a recognized active `FromAgencyId` is attributed to transaction + logs without silently turning the historically informational partner table + into a new authorization gate. `CheckInItem` and `RenewItem` reject a + malformed `UserId`, use a valid optional `UserId` to select the correct loan, + and refuse an ambiguous title-only mutation when several NCIP loans are open; + check-out/check-in/renew are logged to `ncip_transactions`; renewals claim + the window from the day after the current due date (#336 parity with web). +- **Return flow**: a deadlock/lock-timeout now reports a dedicated + "another operation was updating this book, retry" error instead of the + generic failure; reservation availability emails format dates in the + recipient's language like the rest of the #360 pipeline; cancelling a + promoted pending loan releases and reassigns its copy immediately. +- **Admin reservation edits**: an out-of-whitelist status is rejected instead + of silently coerced to `attiva`, and a completed (promoted) reservation + cannot be flipped while its converted loan is still open. +- **Localized gender on the admin user-details page** (#371): the stored + `sesso` enum (`M`/`F`/`Altro`) now renders through the same translated labels + as the edit form instead of the raw code, with a safe fallback for unexpected + values. +- **Notifications dropdown no longer clips on phones**: the panel was anchored + to the right-hand bell button, so a near-full-width dropdown overflowed the + right screen edge and cut off "Segna tutte come lette" and the notification + bodies. Below the `md` breakpoint it is now pinned to the viewport and + centred; the desktop panel is unchanged. + +### Changed + +- Release artifacts now have one canonical producer: the tag-triggered + `Verified Release` workflow builds twice, verifies the package, uploads a + draft, checks every server-side digest and only then publishes it. The local + release script performs preflight, pushes the annotated tag and monitors that + workflow instead of racing it with a second upload path. +- `reassignOnNewCopy` now walks the whole blocked-hold FIFO and assigns the + first compatible candidate (skipping an incompatible head) instead of + stopping at the first blocked hold; a same-borrower conflict on the target + copy blocks regardless of dates. Observable also with the multiplicity + setting off: a younger reservation can receive a returned copy while the + head stays blocked, without losing its priority for future copies. + ## [0.7.63] - 2026-08-20 ### Fixed diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index 0479fe004..3855fd348 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -164,6 +164,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R } } $loanId = (int) ($data['loan_id'] ?? 0); + $automaticApproval = (bool) $request->getAttribute('automatic_loan_approval', false); if ($loanId <= 0) { $response->getBody()->write(json_encode(['success' => false, 'message' => __('ID prestito non valido')])); @@ -192,6 +193,21 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R } $libroId = (int) $bookRow['libro_id']; + // Runtime compatibility for installations that have not applied + // 0.7.64 yet. Run any self-healing DDL before begin_transaction(): + // ALTER TABLE would otherwise implicitly commit the circulation + // transaction. If the DB user cannot ALTER, approval still works; + // only the pre-commit email claim is temporarily unavailable. + $notificationService = new \App\Support\NotificationService($db); + $pickupNotificationSchemaAvailable = \App\Support\PickupNotificationSchema::ensure($db); + $pickupNotificationClaimAvailable = $automaticApproval && $pickupNotificationSchemaAvailable; + $pickupNotificationClaimToken = null; + $pickupNotificationResetSql = $pickupNotificationSchemaAvailable + ? ", pickup_notification_sent = 0, + pickup_notification_claim_token = NULL, + pickup_notification_last_attempt_at = NULL" + : ''; + $db->begin_transaction(); // Lock della riga `libri` PRIMA — serializza anche le approvazioni dello @@ -244,6 +260,20 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // DateHelper::today() incapsula già i fallback sul timezone (M9). $today = DateHelper::today(); + // BUG8/D13 parity con activateScheduledLoans: una richiesta la cui + // finestra è interamente trascorsa non è approvabile. Senza questa + // guardia nascerebbe un 'da_ritirare' con deadline già passata (mai + // ritirabile) e, con :fine nel passato, i predicati di overlap sotto + // diventerebbero ciechi ai prestiti correnti. + if ($dataScadenza !== null && $dataScadenza !== '' && $dataScadenza < $today) { + $db->rollback(); + $response->getBody()->write(json_encode([ + 'success' => false, + 'message' => __('La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo') + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(400); + } + $utenteId = (int) $loan['utente_id']; // M7 — gate di idoneità anche in APPROVAZIONE: l'utente può essere @@ -267,20 +297,11 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R return $response->withHeader('Content-Type', 'application/json')->withStatus(403); } - $dupStmt = $db->prepare(" - SELECT id FROM prestiti - WHERE libro_id = ? AND utente_id = ? AND id != ? - AND ( - (attivo = 1 AND stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) - OR (attivo = 0 AND stato = 'pendente') - ) - LIMIT 1 - "); - $dupStmt->bind_param('iii', $libroId, $utenteId, $loanId); - $dupStmt->execute(); - $hasActiveDuplicate = $dupStmt->get_result()->num_rows > 0; - $dupStmt->close(); - if ($hasActiveDuplicate) { + // Approval atomically assigns a locked physical copy below, so the + // opt-in multiplicity policy may coexist with other copy-bound loans. + // Sibling pending/copyless rows remain blocking in every mode. + $multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($db); + if ($multiplicityPolicy->hasBlockingLoan($libroId, $utenteId, true, $loanId)) { $db->rollback(); $response->getBody()->write(json_encode([ 'success' => false, @@ -288,6 +309,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R ])); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } + $borrowerCommittedCopyIds = $multiplicityPolicy->committedCopyIds($libroId, $utenteId, $loanId); $dupReservationStmt = $db->prepare(" SELECT id @@ -364,24 +386,31 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // If loan already has a valid assigned copy, we can skip global slot counting $selectedCopy = null; - if ($existingCopiaId !== null) { + if ($existingCopiaId !== null && !in_array($existingCopiaId, $borrowerCommittedCopyIds, true)) { + // Solo copie in sede ('disponibile'/'prenotato') e appartenenti al + // libro del prestito: una copia_id corrotta o una copia 'prestato' + // (fuori con un altro prestito) non è assegnabile in approvazione. + // #366 residual: un 'in_corso' scaduto per data ma non ancora + // flippato dal cron è la stessa copia non rientrata — bloccante + // come 'in_ritardo' (stesso ramo di CapacityService). $existingCopyStmt = $db->prepare(" SELECT c.id FROM copie c WHERE c.id = ? + AND c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.id != ? AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' OR (p.stato = 'in_corso' AND p.data_scadenza < ?) OR p.data_scadenza >= ?) AND ( (p.attivo = 1 AND p.stato IN ('in_corso', 'prenotato', 'da_ritirare', 'in_ritardo')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) ) ) "); - $existingCopyStmt->bind_param('iiss', $existingCopiaId, $loanId, $dataScadenza, $dataPrestito); + $existingCopyStmt->bind_param('iiisss', $existingCopiaId, $libroId, $loanId, $dataScadenza, $today, $dataPrestito); $existingCopyStmt->execute(); $existingCopyResult = $existingCopyStmt->get_result(); $selectedCopy = $existingCopyResult ? $existingCopyResult->fetch_assoc() : null; @@ -409,11 +438,22 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND NOT EXISTS ( + SELECT 1 FROM prestiti own + WHERE own.copia_id = c.id + AND own.libro_id = ? + AND own.utente_id = ? + AND own.id != ? + AND ( + (own.attivo = 0 AND own.stato = 'pendente') + OR (own.attivo = 1 AND own.stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) + ) + ) AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' OR (p.stato = 'in_corso' AND p.data_scadenza < ?) OR p.data_scadenza >= ?) AND ( (p.attivo = 1 AND p.stato IN ('in_corso', 'prenotato', 'da_ritirare', 'in_ritardo')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) @@ -421,7 +461,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R ) LIMIT 1 "); - $overlapStmt->bind_param('iss', $libroId, $dataScadenza, $dataPrestito); + $overlapStmt->bind_param('iiiisss', $libroId, $libroId, $utenteId, $loanId, $dataScadenza, $today, $dataPrestito); $overlapStmt->execute(); $overlapResult = $overlapStmt->get_result(); $selectedCopy = $overlapResult ? $overlapResult->fetch_assoc() : null; @@ -433,6 +473,10 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // Fallback: try date-aware method to find available copy for the requested period $copyRepo = new \App\Models\CopyRepository($db); $availableCopies = $copyRepo->getAvailableByBookIdForDateRange($libroId, $dataPrestito, $dataScadenza); + $availableCopies = array_values(array_filter( + $availableCopies, + static fn (array $copy): bool => !in_array((int) $copy['id'], $borrowerCommittedCopyIds, true) + )); if (empty($availableCopies)) { $db->rollback(); @@ -460,7 +504,8 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $overlapCopyStmt = $db->prepare(" SELECT 1 FROM prestiti WHERE copia_id = ? AND id != ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND data_prestito <= ? + AND (stato = 'in_ritardo' OR (stato = 'in_corso' AND data_scadenza < ?) OR data_scadenza >= ?) AND ( (attivo = 1 AND stato IN ('in_corso','prenotato','da_ritirare','in_ritardo')) OR (stato = 'pendente' AND copia_id IS NOT NULL) @@ -468,7 +513,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R LIMIT 1 FOR UPDATE "); - $overlapCopyStmt->bind_param('iiss', $selectedCopy['id'], $loanId, $dataScadenza, $dataPrestito); + $overlapCopyStmt->bind_param('iisss', $selectedCopy['id'], $loanId, $dataScadenza, $today, $dataPrestito); $overlapCopyStmt->execute(); $overlapCopy = $overlapCopyStmt->get_result()->fetch_assoc(); $overlapCopyStmt->close(); @@ -489,7 +534,9 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $copyResult = $copyCheckStmt->get_result()->fetch_assoc(); $copyCheckStmt->close(); - $invalidStates = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento']; + // 'prestato' incluso: una copia ancora fuori con un altro prestito + // non è assegnabile in approvazione (double-issue, vedi confirmPickup). + $invalidStates = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento', 'prestato']; if (!$copyResult || in_array($copyResult['stato'], $invalidStates, true)) { throw new \RuntimeException(__('Copia non disponibile per il prestito')); } @@ -503,14 +550,14 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R if ($pickupDeadline !== null) { $stmt = $db->prepare(" UPDATE prestiti - SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = ? + SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = ?{$pickupNotificationResetSql} WHERE id = ? AND stato = 'pendente' "); $stmt->bind_param('sisi', $newState, $selectedCopy['id'], $pickupDeadline, $loanId); } else { $stmt = $db->prepare(" UPDATE prestiti - SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = NULL + SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = NULL{$pickupNotificationResetSql} WHERE id = ? AND stato = 'pendente' "); $stmt->bind_param('sii', $newState, $selectedCopy['id'], $loanId); @@ -518,6 +565,30 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $stmt->execute(); $stmt->close(); + // In the immediate auto-approval flow the generic approval email is + // also the pickup announcement (#301). Claim it before commit so a + // concurrent retry sweep can never send loan_pickup_ready in the + // interval between commit and the approval email. A failed send + // releases the claim below for the normal retry pipeline. + if (!$isFutureLoan && $automaticApproval && $pickupNotificationClaimAvailable) { + $pickupNotificationClaimToken = bin2hex(random_bytes(16)); + $pickupNotificationAttemptedAt = \App\Support\PickupNotificationSchema::claimLeaseWindow()['attemptedAt']; + $claimStmt = $db->prepare(" + UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ? AND attivo = 1 AND stato = 'da_ritirare' + AND (pickup_notification_sent IS NULL OR pickup_notification_sent = 0) + "); + $claimStmt->bind_param('ssi', $pickupNotificationClaimToken, $pickupNotificationAttemptedAt, $loanId); + $claimStmt->execute(); + if ($claimStmt->affected_rows !== 1) { + $pickupNotificationClaimToken = null; + } + $claimStmt->close(); + } + // Per 'da_ritirare' e 'prenotato', la copia resta 'prenotato' fino al ritiro // La copia diventa 'prestato' SOLO quando si conferma il ritiro @@ -533,16 +604,57 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // the approval email in the auto-approval flow; manual immediate // approvals retain the more specific pickup-ready notification. try { - $notificationService = new \App\Support\NotificationService($db); - $automaticApproval = (bool) $request->getAttribute('automatic_loan_approval', false); if ($isFutureLoan || $automaticApproval) { // Future loan: send general approval notification - $notificationService->sendLoanApprovedNotification($loanId); + $approvalEmailSent = $notificationService->sendLoanApprovedNotification($loanId); + if (!$isFutureLoan && $pickupNotificationClaimToken !== null && $approvalEmailSent) { + $ownedToken = $pickupNotificationClaimToken; + // Delivery won: a cleanup failure must leave sent=1, + // never re-arm an already delivered announcement. + $pickupNotificationClaimToken = null; + try { + $finalizeClaimStmt = $db->prepare(" + UPDATE prestiti SET pickup_notification_claim_token = NULL + WHERE id = ? AND pickup_notification_claim_token = ? + "); + $finalizeClaimStmt->bind_param('is', $loanId, $ownedToken); + $finalizeClaimStmt->execute(); + $finalizeClaimStmt->close(); + } catch (\Throwable $finalizeError) { + \App\Support\SecureLogger::warning("Failed to finalize auto-approval pickup claim for loan {$loanId}: " . $finalizeError->getMessage()); + } + } elseif (!$isFutureLoan && $pickupNotificationClaimToken !== null) { + $releaseClaimStmt = $db->prepare(" + UPDATE prestiti + SET pickup_notification_sent = 0, + pickup_notification_claim_token = NULL + WHERE id = ? AND pickup_notification_claim_token = ? + "); + $releaseClaimStmt->bind_param('is', $loanId, $pickupNotificationClaimToken); + $releaseClaimStmt->execute(); + $releaseClaimStmt->close(); + $pickupNotificationClaimToken = null; + } } else { // Immediate loan (da_ritirare): send pickup ready notification with deadline $notificationService->sendPickupReadyNotification($loanId); } } catch (\Throwable $notifError) { + if (!$isFutureLoan && $pickupNotificationClaimToken !== null) { + try { + $releaseClaimStmt = $db->prepare(" + UPDATE prestiti + SET pickup_notification_sent = 0, + pickup_notification_claim_token = NULL + WHERE id = ? AND pickup_notification_claim_token = ? + "); + $releaseClaimStmt->bind_param('is', $loanId, $pickupNotificationClaimToken); + $releaseClaimStmt->execute(); + $releaseClaimStmt->close(); + } catch (\Throwable $releaseError) { + \App\Support\SecureLogger::error("Failed to release auto-approval pickup claim for loan {$loanId}: " . $releaseError->getMessage()); + } + } \App\Support\SecureLogger::warning("Approval notification failed for loan {$loanId}: " . $notifError->getMessage()); // Don't fail the approval if notification fails } @@ -932,6 +1044,35 @@ public function confirmPickup(Request $request, Response $response, mysqli $db): ])); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } + // Non fidarsi del solo copie.stato (pre-0.7.62 / dati legacy): la + // copia può risultare 'prenotato' mentre un'ALTRA riga aperta la + // tiene ancora impegnata. Stesso ricontrollo per-riga usato da + // MaintenanceService::activateScheduledLoans. + $copyConflictStmt = $db->prepare(" + SELECT 1 + FROM prestiti + WHERE copia_id = ? AND id <> ? + AND ( (attivo = 1 AND stato IN ('in_corso','in_ritardo','da_ritirare')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) + OR (attivo = 1 AND stato = 'prenotato' + AND data_prestito <= ? AND data_scadenza >= ?) ) + LIMIT 1 + FOR UPDATE + "); + $copyConflictStmt->bind_param('iiss', $copiaId, $loanId, $loan['data_scadenza'], $loan['data_prestito']); + $copyConflictStmt->execute(); + $copyHeld = (bool) $copyConflictStmt->get_result()->fetch_row(); + $copyConflictStmt->close(); + if ($copyHeld) { + $db->rollback(); + \App\Support\SecureLogger::error("[confirmPickup] Loan {$loanId} aborted: copy {$copiaId} held by another open loan row"); + $response->getBody()->write(json_encode([ + 'success' => false, + 'message' => __('La copia assegnata non è prestabile. Riassegna la copia o annulla il ritiro.') + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(400); + } + $copyRepo = new \App\Models\CopyRepository($db); $copyRepo->updateStatus($copiaId, 'prestato'); } @@ -980,7 +1121,15 @@ public function cancelPickup(Request $request, Response $response, mysqli $db): } } $loanId = (int) ($data['loan_id'] ?? 0); - $reason = $data['reason'] ?? __('Ritiro non effettuato'); + // Stessa normalizzazione di rejectLoan/cancelReservation: un array farebbe + // TypeError nel template email, una stringa illimitata finirebbe + // integralmente nell'email e nella nota di audit. + $reason = $data['reason'] ?? ''; + $reason = is_scalar($reason) ? trim((string) $reason) : ''; + if ($reason === '') { + $reason = __('Ritiro non effettuato'); + } + $reason = mb_substr($reason, 0, 500); if ($loanId <= 0) { $response->getBody()->write(json_encode(['success' => false, 'message' => __('ID prestito non valido')])); @@ -1060,13 +1209,21 @@ public function cancelPickup(Request $request, Response $response, mysqli $db): $copiaId = $loan['copia_id'] ? (int) $loan['copia_id'] : null; - // Mark loan as expired (not picked up) + // Mark loan as expired (not picked up). Nota di audit + processed_by + // come per checkExpiredPickups/rejectLoan: senza, l'annullamento + // manuale dello staff e la scadenza automatica del cron sarebbero + // indistinguibili a posteriori. + $cancelledBy = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $noteSuffix = "\n[Staff] " . __('Ritiro annullato il') . ' ' + . implode('/', array_reverse(explode('-', $today))) . ' — ' . $reason; $updateStmt = $db->prepare(" UPDATE prestiti - SET stato = 'scaduto', attivo = 0, pickup_deadline = NULL + SET stato = 'scaduto', attivo = 0, pickup_deadline = NULL, + processed_by = COALESCE(?, processed_by), + note = CONCAT(COALESCE(note, ''), ?) WHERE id = ? "); - $updateStmt->bind_param('i', $loanId); + $updateStmt->bind_param('isi', $cancelledBy, $noteSuffix, $loanId); $updateStmt->execute(); $updateStmt->close(); diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index 2d396dc50..67e2e9bf6 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -87,6 +87,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $total_stmt->execute(); $total_res = $total_stmt->get_result(); $total = (int)($total_res->fetch_assoc()['c'] ?? 0); + $total_stmt->close(); // Use prepared statement for filtered count to prevent SQL injection $count_sql = "SELECT COUNT(*) AS c $base $where_prepared"; @@ -103,6 +104,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $count_stmt->execute(); $filteredRes = $count_stmt->get_result(); $filtered = (int)($filteredRes->fetch_assoc()['c'] ?? 0); + $count_stmt->close(); // Handle DataTables ordering $orderColumn = 'p.id'; diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index fa2079490..2fd1090a3 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -172,8 +172,11 @@ public function createForm(Request $request, Response $response, mysqli $db): Re // il vecchio date('Y-m-d') (TZ processo, spesso UTC) mostrava "ieri" dopo // mezzanotte, e il '+1 month' della view divergeva dal default server (30gg). $defaultDataPrestito = \App\Support\DateHelper::today(); - $defaultLoanDays = (new \App\Models\SettingsRepository($db))->loanDurationDays(); + $loanSettings = new \App\Models\SettingsRepository($db); + $defaultLoanDays = $loanSettings->loanDurationDays(); + $allowMultipleLoansSameBook = $loanSettings->allowsMultipleLoansSameBook(); $defaultDataScadenza = date('Y-m-d', strtotime($defaultDataPrestito . " +{$defaultLoanDays} days")); + $loanSubmissionToken = \App\Support\OneTimeFormToken::issue('loan.create'); ob_start(); require __DIR__ . '/../Views/prestiti/crea_prestito.php'; @@ -193,6 +196,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons $data = (array) $request->getParsedBody(); // CSRF validated by CsrfMiddleware + // CSRF is intentionally reusable for the session; this additional + // one-time token makes the non-idempotent loan INSERT replay-safe. PHP's + // session lock serializes concurrent POSTs, so exactly one request can + // consume a token even across retries or duplicate browser tabs. + $submissionToken = $data['loan_submission_token'] ?? null; + if (!\App\Support\OneTimeFormToken::consume('loan.create', $submissionToken)) { + return $response->withHeader('Location', url('/admin/loans/create') . '?error=duplicate_submission')->withStatus(302); + } // Preserve the submitted values so a validation/availability error can // re-render the form pre-filled instead of wiping everything. Cleared on @@ -217,7 +228,10 @@ public function store(Request $request, Response $response, mysqli $db): Respons $oldInput[$field] = (string) $data[$field]; } } - $_SESSION['loan_form_old'] = $oldInput; + // In sessione senza copy_code/save_and_new: la view non ripopola mai il + // campo scanner (come da commento in createForm) e i due flag hanno senso + // solo dentro QUESTA request — trattenerli lasciava un residuo inutile. + $_SESSION['loan_form_old'] = array_diff_key($oldInput, ['copy_code' => true, 'save_and_new' => true]); // Verifica dati obbligatori $utente_id = (int) ($oldInput['utente_id'] ?? 0); @@ -272,6 +286,23 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_dates')->withStatus(302); } + // A newly-created active loan whose entire window is already over is + // physically open-ended until returned, even if its nominal dates do + // not overlap a future hold. Reject it at the application boundary so + // crafted staff POSTs cannot rely on the database trigger as the first + // (and less actionable) line of defence. Approval uses the same rule. + if ($data_scadenza < \App\Support\DateHelper::today()) { + return $response->withHeader('Location', url('/admin/loans/create') . '?error=expired_window')->withStatus(302); + } + + // Ensure the pickup claim schema before the INSERT and before opening + // the circulation transaction. On a legacy install, the helper marks + // rows that already existed as historical and then restores DEFAULT 0; + // doing this only after creating the new ready loan would incorrectly + // classify that brand-new row as already announced. Restricted DB + // users safely fall back to the historical one-shot send. + $pickupNotificationSchemaAvailable = \App\Support\PickupNotificationSchema::ensure($db); + $db->begin_transaction(); try { @@ -291,24 +322,16 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/loans/create') . '?error=book_not_found')->withStatus(302); } - // Case 8: Prevent multiple active reservations/loans for the same book by the same user - // Note: 'pendente' has attivo=0, other active states have attivo=1 - $dupStmt = $db->prepare(" - SELECT id FROM prestiti - WHERE libro_id = ? AND utente_id = ? AND ( - (attivo = 0 AND stato = 'pendente') - OR (attivo = 1 AND stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) - ) - FOR UPDATE - "); - $dupStmt->bind_param('ii', $libro_id, $utente_id); - $dupStmt->execute(); - if ($dupStmt->get_result()->num_rows > 0) { - $dupStmt->close(); + // By default the historical borrower/title uniqueness rule remains. + // The opt-in policy relaxes it only for this staff flow, which always + // resolves and locks a physical copy before INSERT. Pending/copyless + // commitments still block, as do active queue reservations below. + $multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($db); + if ($multiplicityPolicy->hasBlockingLoan($libro_id, $utente_id, true)) { $db->rollback(); return $response->withHeader('Location', url('/admin/loans/create') . '?error=duplicate_reservation')->withStatus(302); } - $dupStmt->close(); + $borrowerCommittedCopyIds = $multiplicityPolicy->committedCopyIds($libro_id, $utente_id); $dupReservationStmt = $db->prepare(" SELECT id @@ -385,10 +408,10 @@ public function store(Request $request, Response $response, mysqli $db): Respons // auto-assign paths below (fully backward compatible). $copyCode = trim($oldInput['copy_code'] ?? ''); if ($copyCode !== '') { - // Lock ONLY the copie row here — the requested book is already - // locked above (line ~155), so JOINing+locking libri would lock a - // (potentially different) book row second and invert lock order → - // deadlock risk when a scanned code belongs to another book. + // Resolve without locking first: borrower/title commitments must + // be locked before the selected copy to preserve the canonical + // order. The final copy lock + overlap re-check below is the + // authority immediately before INSERT. // Soft-delete stays covered: the copy_wrong_book check below // requires this copy to belong to $libro_id, which was resolved // under `deleted_at IS NULL`. @@ -397,7 +420,6 @@ public function store(Request $request, Response $response, mysqli $db): Respons FROM copie c WHERE c.numero_inventario = ? LIMIT 1 - FOR UPDATE "); $codeStmt->bind_param('s', $copyCode); $codeStmt->execute(); @@ -415,6 +437,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons $forcedCopyId = (int) $codeRow['id']; + // Multiple open rows for one borrower/title must represent + // different physical items even when their date windows do not + // overlap. The book lock makes this early snapshot race-safe. + if (in_array($forcedCopyId, $borrowerCommittedCopyIds, true)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans/create') . '?error=copy_not_available')->withStatus(302); + } + // Must be in a lendable state and have no overlapping hold for the period. $lendable = !in_array($codeRow['stato'], ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true); if ($lendable) { @@ -422,14 +452,16 @@ public function store(Request $request, Response $response, mysqli $db): Respons SELECT 1 FROM prestiti WHERE copia_id = ? AND data_prestito <= ? - AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ( (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo')) OR (stato = 'pendente' AND copia_id IS NOT NULL) ) LIMIT 1 "); - $ovStmt->bind_param('iss', $forcedCopyId, $data_scadenza, $data_prestito); + $ovStmt->bind_param('isss', $forcedCopyId, $data_scadenza, $today, $data_prestito); $ovStmt->execute(); if ($ovStmt->get_result()->fetch_row()) { $lendable = false; @@ -473,11 +505,23 @@ public function store(Request $request, Response $response, mysqli $db): Respons SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND NOT EXISTS ( + SELECT 1 FROM prestiti own + WHERE own.copia_id = c.id + AND own.libro_id = ? + AND own.utente_id = ? + AND ( + (own.attivo = 0 AND own.stato = 'pendente') + OR (own.attivo = 1 AND own.stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) + ) + ) AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) AND ( (p.attivo = 1 AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) @@ -485,7 +529,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons ) LIMIT 1 "); - $overlapStmt->bind_param('iss', $libro_id, $data_scadenza, $data_prestito); + $overlapStmt->bind_param('iiisss', $libro_id, $libro_id, $utente_id, $data_scadenza, $today, $data_prestito); $overlapStmt->execute(); $overlapResult = $overlapStmt->get_result(); $selectedCopy = $overlapResult ? $overlapResult->fetch_assoc() : null; @@ -498,21 +542,31 @@ public function store(Request $request, Response $response, mysqli $db): Respons } // Lock selected copy and re-check overlap to prevent race conditions - $lockCopyStmt = $db->prepare("SELECT id FROM copie WHERE id = ? FOR UPDATE"); + $lockCopyStmt = $db->prepare("SELECT id, stato, libro_id FROM copie WHERE id = ? FOR UPDATE"); $lockCopyStmt->bind_param('i', $selectedCopy['id']); $lockCopyStmt->execute(); + $lockedCopy = $lockCopyStmt->get_result()->fetch_assoc(); $lockCopyStmt->close(); + if (!$lockedCopy + || (int) $lockedCopy['libro_id'] !== $libro_id + || in_array($lockedCopy['stato'], ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans/create') . '?error=copy_not_available')->withStatus(302); + } // Race condition check to prevent double-booking $overlapCopyStmt = $db->prepare(" SELECT 1 FROM prestiti WHERE copia_id = ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND data_prestito <= ? + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ( (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) ) LIMIT 1 "); - $overlapCopyStmt->bind_param('iss', $selectedCopy['id'], $data_scadenza, $data_prestito); + $overlapCopyStmt->bind_param('isss', $selectedCopy['id'], $data_scadenza, $today, $data_prestito); $overlapCopyStmt->execute(); $overlapCopy = $overlapCopyStmt->get_result()->fetch_assoc(); $overlapCopyStmt->close(); @@ -544,9 +598,13 @@ public function store(Request $request, Response $response, mysqli $db): Respons // è l'unico percorso di creazione diretta da admin — senza il tag // esplicito la colonna resterebbe al default 'richiesta' e l'enum // 'diretto' non verrebbe mai scritto da nessuno. + $pickupInsertColumns = $pickupNotificationSchemaAvailable + ? ', pickup_notification_sent, pickup_notification_claim_token, pickup_notification_last_attempt_at' + : ''; + $pickupInsertValues = $pickupNotificationSchemaAvailable ? ', 0, NULL, NULL' : ''; $stmt = $db->prepare("INSERT INTO prestiti - (libro_id, copia_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, origine, sanzione, renewals, processed_by, note, attivo, pickup_deadline) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, origine, sanzione, renewals, processed_by, note, attivo, pickup_deadline{$pickupInsertColumns}) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{$pickupInsertValues})"); $data_restituzione = null; @@ -673,13 +731,16 @@ public function store(Request $request, Response $response, mysqli $db): Respons SecureLogger::warning(__('Notifica prestito fallita'), ['error' => $e->getMessage()]); } - // "Salva e registra un'altra copia": keep borrower, dates and note, - // but clear both book and copy. Active loans are unique per user/book, - // so retaining the previous book would make the next submit fail the - // duplicate guard. Scanning the next inventory code fills its book. + // "Salva e registra un'altra copia": always clear the physical copy. + // With the opt-in mode enabled, retain the book too so the operator + // can scan several copies of the same title without repeating search. + // Strict mode preserves the historical title-reset behaviour. if (($oldInput['save_and_new'] ?? '') === '1') { $retain = $oldInput; - unset($retain['libro_id'], $retain['copy_code'], $retain['save_and_new']); + unset($retain['copy_code'], $retain['save_and_new']); + if (!$multiplicityPolicy->isEnabled()) { + unset($retain['libro_id']); + } $_SESSION['loan_form_old'] = $retain; $redirectUrl = url('/admin/loans/create') . '?created=1'; $scaricaPdf = ($oldInput['scarica_pdf'] ?? '') === '1'; @@ -814,6 +875,13 @@ public function update(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302); } + // Reassignment resets recipient-specific pickup state. Ensure its + // optional upgrade columns before opening the circulation transaction; + // DDL inside the transaction would implicitly commit it. Restricted + // legacy DB users degrade to recall-only reset until migration 0.7.64. + $pickupNotificationSchemaAvailable = isset($updateData['utente_id']) + && \App\Support\PickupNotificationSchema::ensure($db); + $db->begin_transaction(); try { // ORDINE DI LOCK CANONICO (P3): la riga `libri` PRIMA del prestito, @@ -833,7 +901,14 @@ public function update(Request $request, Response $response, mysqli $db, int $id // iniziale e questo lock le può aver cambiate, e la finestra "vecchia" // del check di capacità qui sotto deve basarsi sui valori realmente // salvati, non su quelli pre-transazione (CodeRabbit, PR #337). - $lockLoan = $db->prepare('SELECT attivo, data_restituzione, libro_id, copia_id, utente_id, stato, data_prestito, data_scadenza FROM prestiti WHERE id=? FOR UPDATE'); + $pickupClaimSelect = $pickupNotificationSchemaAvailable + ? ', pickup_notification_claim_token, pickup_notification_last_attempt_at' + : ''; + $lockLoan = $db->prepare( + 'SELECT attivo, data_restituzione, libro_id, copia_id, utente_id, stato, origine, data_prestito, data_scadenza' + . $pickupClaimSelect + . ' FROM prestiti WHERE id=? FOR UPDATE' + ); $lockLoan->bind_param('i', $id); $lockLoan->execute(); $locked = $lockLoan->get_result()->fetch_assoc(); @@ -854,6 +929,25 @@ public function update(Request $request, Response $response, mysqli $db, int $id $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $locked['utente_id']; $newPrestito = (string) ($updateData['data_prestito'] ?? $locked['data_prestito']); $newScadenza = (string) ($updateData['data_scadenza'] ?? $locked['data_scadenza']); + + // A pickup sender owns an immutable recipient snapshot while its + // short claim is live. Do not let a reassignment commit behind that + // sender and deliver to the previous borrower. An orphaned claim no + // longer blocks once its lease expires: the reassignment below owns + // the locked row and clears the stale token atomically. + if ($newUserId !== (int) $locked['utente_id'] + && $pickupNotificationSchemaAvailable + && \App\Support\PickupNotificationSchema::isClaimLive( + isset($locked['pickup_notification_claim_token']) + ? (string) $locked['pickup_notification_claim_token'] + : null, + isset($locked['pickup_notification_last_attempt_at']) + ? (string) $locked['pickup_notification_last_attempt_at'] + : null + )) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=concurrent_retry')->withStatus(302); + } if (!\App\Support\DateHelper::isISODateFormat($newPrestito) || !\App\Support\DateHelper::isISODateFormat($newScadenza)) { $db->rollback(); return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_date_format')->withStatus(302); @@ -862,6 +956,18 @@ public function update(Request $request, Response $response, mysqli $db, int $id $db->rollback(); return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302); } + // F1: actively backdating the due date into the past makes an active + // 'in_corso' row open-ended for the per-copy overlap trigger, which + // then rejects the UPDATE with an opaque 'loan_update_failed'. Guard + // it up front with the same clear error store()/approve() surface, + // but only when the due date is actually being changed — editing an + // already-overdue loan's other fields (its due date unchanged and + // legitimately in the past) must still succeed. + if ($newScadenza !== (string) $locked['data_scadenza'] + && $newScadenza < \App\Support\DateHelper::today()) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=expired_window')->withStatus(302); + } // Se l'utente cambia, ri-esegui i controlli di store() (M6b): il campo // arriva da hidden field e senza ricontrolli permetterebbe di aggirare @@ -882,21 +988,29 @@ public function update(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/loans') . '?error=' . $eligibilityError)->withStatus(302); } - // Dup-check (libro, nuovo utente) sugli stati attivi, escludendo il - // prestito in modifica — stesso predicato di store(). - $dupStmt = $db->prepare(" - SELECT id FROM prestiti - WHERE libro_id = ? AND utente_id = ? AND id <> ? AND ( - (attivo = 0 AND stato = 'pendente') - OR (attivo = 1 AND stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) - ) - FOR UPDATE - "); - $dupStmt->bind_param('iii', $libroId, $newUserId, $id); - $dupStmt->execute(); - $hasDup = $dupStmt->get_result()->num_rows > 0; - $dupStmt->close(); - if ($hasDup) { + // Reassignment may use the relaxed rule only when this existing + // loan is already tied to a physical copy. Legacy copyless rows + // deliberately retain strict title-level uniqueness. Una riga + // PROMOSSA dalla coda (origine='prenotazione' in prenotato/ + // da_ritirare) resta a sua volta un impegno di coda: riassegnarla + // in modalità rilassata darebbe al nuovo utente un prestito fisico + // PIÙ una posizione di coda per lo stesso titolo — lo stato che + // store()/approveLoan() vietano nella direzione opposta. + $isQueueCommitment = ($locked['origine'] ?? null) === 'prenotazione' + && in_array((string) $locked['stato'], ['prenotato', 'da_ritirare'], true); + $multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($db); + if ($multiplicityPolicy->hasBlockingLoan( + $libroId, + $newUserId, + $locked['copia_id'] !== null && !$isQueueCommitment, + $id + )) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=duplicate_reservation')->withStatus(302); + } + $committedCopyIds = $multiplicityPolicy->committedCopyIds($libroId, $newUserId, $id); + if ($locked['copia_id'] !== null + && in_array((int) $locked['copia_id'], $committedCopyIds, true)) { $db->rollback(); return $response->withHeader('Location', url('/admin/loans') . '?error=duplicate_reservation')->withStatus(302); } @@ -986,17 +1100,20 @@ public function update(Request $request, Response $response, mysqli $db, int $id // conflict is detected before the write and reported truthfully. $copyId = $locked['copia_id'] !== null ? (int) $locked['copia_id'] : null; if ($copyId !== null && $claimedWindows !== []) { + $applicationToday = \App\Support\DateHelper::today(); $copyOverlap = $db->prepare( "SELECT 1 FROM prestiti WHERE copia_id = ? AND id <> ? AND data_prestito <= ? - AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL)) LIMIT 1" ); foreach ($claimedWindows as [$claimStart, $claimEnd]) { - $copyOverlap->bind_param('iiss', $copyId, $id, $claimEnd, $claimStart); + $copyOverlap->bind_param('iisss', $copyId, $id, $claimEnd, $applicationToday, $claimStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { $copyOverlap->close(); @@ -1052,13 +1169,26 @@ public function update(Request $request, Response $response, mysqli $db, int $id $recalcStato->bind_param('sssi', $today, $today, $today, $id); $recalcStato->execute(); $recalcStato->close(); - } elseif ($newUserId !== (int) $locked['utente_id']) { + } + + if ($newUserId !== (int) $locked['utente_id']) { // A recall belongs to the recipient who received it. Reassigning // the loan must not carry that recipient's count/cooldown over to - // the new user, even when the due date itself is unchanged. - $resetRecall = $db->prepare( - 'UPDATE prestiti SET recall_count = 0, last_recall_at = NULL WHERE id = ?' - ); + // the new user, even when the due date itself is unchanged. The + // ready-for-pickup claim belongs to that recipient too: release + // it for a da_ritirare row so the hourly retry notifies the new + // borrower. + $pickupReset = $pickupNotificationSchemaAvailable + ? ",\n pickup_notification_sent = CASE WHEN stato = 'da_ritirare' THEN 0 ELSE pickup_notification_sent END, + pickup_notification_claim_token = CASE WHEN stato = 'da_ritirare' THEN NULL ELSE pickup_notification_claim_token END, + pickup_notification_last_attempt_at = CASE WHEN stato = 'da_ritirare' THEN NULL ELSE pickup_notification_last_attempt_at END" + : ''; + $resetRecall = $db->prepare(" + UPDATE prestiti + SET recall_count = 0, + last_recall_at = NULL{$pickupReset} + WHERE id = ? + "); $resetRecall->bind_param('i', $id); $resetRecall->execute(); $resetRecall->close(); @@ -1446,11 +1576,17 @@ public function processReturn(Request $request, Response $response, mysqli $db, } catch (\Throwable $e) { $db->rollback(); SecureLogger::error(__('Errore elaborazione restituzione'), ['loan_id' => $id, 'error' => $e->getMessage()]); + // Deadlock (1213) o lock wait timeout (1205): la transazione è stata + // annullata integralmente, ritentare è sicuro e quasi sempre risolve. + // Un errore dedicato evita il generico "update_failed" che non dice + // all'operatore che basta ripetere l'operazione. + $errno = $e instanceof \mysqli_sql_exception ? (int) $e->getCode() : 0; + $errorCode = in_array($errno, [1213, 1205], true) ? 'concurrent_retry' : 'update_failed'; if ($redirectTo) { $separator = strpos($redirectTo, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($redirectTo . $separator . 'error=update_failed'))->withStatus(302); + return $response->withHeader('Location', url($redirectTo . $separator . 'error=' . $errorCode))->withStatus(302); } - return $response->withHeader('Location', url('/admin/loans/returned/' . $id) . '?error=update_failed')->withStatus(302); + return $response->withHeader('Location', url('/admin/loans/returned/' . $id) . '?error=' . $errorCode)->withStatus(302); } } @@ -1687,7 +1823,9 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re "SELECT 1 FROM prestiti WHERE copia_id = ? AND id <> ? AND data_prestito <= ? - AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL)) LIMIT 1" @@ -1794,7 +1932,7 @@ private function applyBulkLoanExtension( $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { - $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $extensionStart); + $copyOverlap->bind_param('iisss', $copyId, $loanId, $newDueDate, $today, $extensionStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { return null; @@ -2010,12 +2148,15 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $copyOvlStmt = $db->prepare(" SELECT 1 FROM prestiti WHERE copia_id = ? AND id <> ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND data_prestito <= ? + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) ) LIMIT 1 "); - $copyOvlStmt->bind_param('iiss', $copiaId, $id, $extensionEnd, $extensionStart); + $copyOvlStmt->bind_param('iisss', $copiaId, $id, $extensionEnd, $todayApp, $extensionStart); $copyOvlStmt->execute(); $hasCopyOverlap = (bool) $copyOvlStmt->get_result()->fetch_row(); $copyOvlStmt->close(); diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php index f3d097216..ffe8b01da 100644 --- a/app/Controllers/ReservationManager.php +++ b/app/Controllers/ReservationManager.php @@ -180,30 +180,59 @@ public function processBookAvailability($bookId) return false; } - // Get the next date-eligible reservation in queue - // Only process reservations where start date <= today (ready to convert to loan) + // Get the next date- and borrower-eligible reservation in queue. + // Eligibility belongs inside the locking query: selecting a fixed + // batch and discarding invalid patrons afterwards could leave a + // valid position 26 blocked forever behind 25 suspended accounts. // FOR UPDATE (locking/current read): the book-level lock serializes // writers that follow the canonical order, but a CALLER's REPEATABLE // READ snapshot can predate that lock — a plain read here would then // still see a reservation that a competitor cancelled and committed // while we waited for the book lock, and promote/email it anyway. // The locking read always returns the latest committed row state. + // Idoneità utente anche in promozione (M7 parity con store/approve): + // un utente sospeso o con tessera scaduta mentre era in coda non va + // promosso — la riga diverrebbe 'completata' + pendente con copia + // che l'approvazione rifiuta comunque (403), bruciando la posizione + // e tenendo la copia impegnata finché un admin non ripulisce a mano. + // Le prenotazioni di utenti sospesi restano attive e continuano a + // impegnare capacità, ma non devono amplificare i lock o rendere + // irraggiungibile il primo candidato idoneo. Il predicato SQL cerca + // direttamente il primo idoneo in FIFO; checkUser() rimane il gate + // autorevole sotto la stessa transazione. $stmt = $this->db->prepare(" SELECT r.*, u.email, u.nome, u.cognome FROM prenotazioni r JOIN utenti u ON r.utente_id = u.id WHERE r.libro_id = ? AND r.stato = 'attiva' AND " . \App\Support\LoanEligibility::promotableReservationWhere('r') . " + AND " . \App\Support\LoanEligibility::eligibleUserWhere('u') . " ORDER BY r.queue_position ASC LIMIT 1 FOR UPDATE "); - $stmt->bind_param('iss', $bookId, $today, $today); + $stmt->bind_param('isss', $bookId, $today, $today, $today); $stmt->execute(); $result = $stmt->get_result(); - $nextReservation = $result->fetch_assoc(); + $nextReservation = $result ? $result->fetch_assoc() : null; $stmt->close(); + if ($nextReservation !== null) { + $eligibilityError = \App\Support\LoanEligibility::checkUser( + $this->db, + (int) $nextReservation['utente_id'] + ); + if ($eligibilityError !== null) { + \App\Support\SecureLogger::info('Promozione prenotazione saltata: utente non idoneo', [ + 'prenotazione_id' => (int) $nextReservation['id'], + 'libro_id' => $bookId, + 'utente_id' => (int) $nextReservation['utente_id'], + 'motivo' => $eligibilityError, + ]); + $nextReservation = null; + } + } + if ($nextReservation) { // Check if the desired date range is available. Resolve the canonical // R_END once: a legacy prenotazione may have data_fine_richiesta NULL but @@ -399,15 +428,33 @@ private function createLoanFromReservation($reservation) // it becomes eligible through the return/reassignment path. // The NOT EXISTS clause ensures no overlapping loans for the requested dates // Note: 'da_ritirare' copies are still 'disponibile' but have a loan reservation + // Come per gli altri allocatori (approveLoan, activateScheduledLoans, + // findAvailableCopyExcluding): mai una copia già impegnata dallo + // STESSO utente per questo titolo, a prescindere dalle date — due + // righe aperte devono rappresentare due copie fisiche distinte. + // #366 residual: un 'in_corso' scaduto per data ma non ancora + // flippato dal cron blocca come 'in_ritardo' (open-ended). + $today = \App\Support\DateHelper::today(); + $promotedUserId = (int) $reservation['utente_id']; $copyStmt = $this->db->prepare(" SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND NOT EXISTS ( + SELECT 1 FROM prestiti own + WHERE own.copia_id = c.id + AND own.libro_id = ? + AND own.utente_id = ? + AND ( + (own.attivo = 0 AND own.stato = 'pendente') + OR (own.attivo = 1 AND own.stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) + ) + ) AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' OR (p.stato = 'in_corso' AND p.data_scadenza < ?) OR p.data_scadenza >= ?) AND ( (p.attivo = 1 AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) -- pending conversion holds this copy (#157, model A-refined) @@ -415,7 +462,7 @@ private function createLoanFromReservation($reservation) ) LIMIT 1 "); - $copyStmt->bind_param('iss', $bookId, $endDate, $startDate); + $copyStmt->bind_param('iiisss', $bookId, $bookId, $promotedUserId, $endDate, $today, $startDate); $copyStmt->execute(); $copyResult = $copyStmt->get_result(); $copy = $copyResult->fetch_assoc(); @@ -442,15 +489,23 @@ private function createLoanFromReservation($reservation) $overlapCopyStmt = $this->db->prepare(" SELECT 1 FROM prestiti WHERE copia_id = ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) AND ( - (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo')) - OR (stato = 'pendente' AND copia_id IS NOT NULL) -- pending conversion holds this copy (#157, model A-refined) + (utente_id = ? AND libro_id = ? + AND ( (attivo = 0 AND stato = 'pendente') + OR (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo')) )) + OR ( + data_prestito <= ? + AND (stato = 'in_ritardo' OR (stato = 'in_corso' AND data_scadenza < ?) OR data_scadenza >= ?) + AND ( + (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo')) + OR (stato = 'pendente' AND copia_id IS NOT NULL) -- pending conversion holds this copy (#157, model A-refined) + ) + ) ) LIMIT 1 FOR UPDATE "); - $overlapCopyStmt->bind_param('iss', $copyId, $endDate, $startDate); + $overlapCopyStmt->bind_param('iiisss', $copyId, $promotedUserId, $bookId, $endDate, $today, $startDate); $overlapCopyStmt->execute(); $overlapCopy = $overlapCopyStmt->get_result()->fetch_assoc(); $overlapCopyStmt->close(); @@ -600,24 +655,22 @@ private function sendReservationNotification(array $reservation): bool 'autore' => $book['autore'] ?? '' ]); - // Format dates according to installation locale for email templates - $locale = \App\Support\I18n::getInstallationLocale(); - $isItalian = str_starts_with($locale, 'it'); - $dateFormat = $isItalian ? 'd-m-Y' : 'Y-m-d'; + // #360 parity: formatta le date nella lingua del DESTINATARIO come + // tutta la pipeline email (warning/overdue/solleciti), non nel + // locale di installazione. + $notificationService = new \App\Support\NotificationService($this->db); + $recipientLocale = $notificationService->resolveRecipientLocale((string) $reservation['email']); $variables = [ 'utente_nome' => $reservation['nome'], 'libro_titolo' => $book['titolo'], 'libro_autore' => $book['autore'] ?: 'Autore non specificato', 'libro_isbn' => $book['isbn'] ?: 'N/A', - 'data_inizio' => date($dateFormat, strtotime($reservation['data_inizio_richiesta'])), - 'data_fine' => date($dateFormat, strtotime($reservation['data_fine_richiesta'])), + 'data_inizio' => $notificationService->formatEmailDate((string) $reservation['data_inizio_richiesta'], false, $recipientLocale), + 'data_fine' => $notificationService->formatEmailDate((string) $reservation['data_fine_richiesta'], false, $recipientLocale), 'book_url' => absoluteUrl($bookLink), 'profile_url' => absoluteUrl(RouteTranslator::route('profile')) ]; - - // Use NotificationService for consistent email handling - $notificationService = new \App\Support\NotificationService($this->db); $success = $notificationService->sendReservationBookAvailable( $reservation['email'], $variables diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index 2204d1451..be27f4373 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -152,8 +152,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id $startDt = $start . ' 00:00:00'; $endDt = $end . ' 23:59:59'; + // Rifiuta (non coercire) uno stato fuori whitelist: la vecchia + // coercizione ad 'attiva' faceva sì che un POST malformato su una + // prenotazione annullata/completata la riattivasse silenziosamente. if (!in_array($stato, ['attiva', 'completata', 'annullata'], true)) { - $stato = 'attiva'; + return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?error=invalid_status')->withStatus(302); } $lookupStmt = $db->prepare("SELECT libro_id FROM prenotazioni WHERE id = ?"); @@ -193,6 +196,32 @@ public function update(Request $request, Response $response, mysqli $db, int $id $utenteId = (int) $libroResult['utente_id']; $oldStato = (string) $libroResult['stato']; + // Una prenotazione 'completata' è stata promossa: il prestito + // collegato vive solo tramite origine='prenotazione' (nessuna FK) e + // questo edit non lo toccherebbe. Finché quel prestito è aperto, + // cambiare qui lo stato produrrebbe solo coppie incoerenti + // (prenotazione annullata + prestito promosso vivo): va gestito il + // prestito, non la prenotazione. + if ($oldStato === 'completata' && $stato !== 'completata') { + $promotedStmt = $db->prepare(" + SELECT id FROM prestiti + WHERE libro_id = ? AND utente_id = ? AND origine = 'prenotazione' AND ( + (attivo = 0 AND stato = 'pendente') + OR (attivo = 1 AND stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo')) + ) + LIMIT 1 + FOR UPDATE + "); + $promotedStmt->bind_param('ii', $libroId, $utenteId); + $promotedStmt->execute(); + $hasPromotedLoan = $promotedStmt->get_result()->num_rows > 0; + $promotedStmt->close(); + if ($hasPromotedLoan) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?error=promoted_loan_active')->withStatus(302); + } + } + if ($stato === 'attiva') { $dupReservationStmt = $db->prepare(" SELECT id @@ -517,6 +546,12 @@ public function store(Request $request, Response $response, mysqli $db): Respons // already at capacity for that window (counting other commitments). // Same CapacityService predicate as the promotion gate and the auditor. $capacity = new \App\Services\CapacityService($db); + // Senza righe in `copie` la promozione non può mai convertire la + // coda (seleziona solo copie fisiche): rifiuta a monte. + if (!$capacity->hasPhysicalCopies($libroId)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/reservations/create') . '?error=capacity_full')->withStatus(302); + } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeUserId: $utenteId)) { $db->rollback(); return $response->withHeader('Location', url('/admin/reservations/create') . '?error=capacity_full')->withStatus(302); diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 4bf4c472f..ac97ad803 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -38,69 +38,12 @@ public function __construct(?mysqli $db = null) throw new Exception("Connection failed: " . $this->db->connect_error); } $this->db->set_charset($cfg['charset']); + \App\Support\DateHelper::synchronizeDatabaseSession($this->db); } - public function getBookAvailability($request, $response, $args) - { - $bookId = (int) $args['id']; - - // A soft-deleted (or non-existent) book must not leak availability — getBookTotalCopies() - // counts copie rows directly, which survive the book's soft-delete, so this public - // endpoint would otherwise serve real counts for an invisible book. - $existsStmt = $this->db->prepare("SELECT 1 FROM libri WHERE id = ? AND deleted_at IS NULL LIMIT 1"); - $existsStmt->bind_param('i', $bookId); - $existsStmt->execute(); - $bookExists = $existsStmt->get_result()->num_rows > 0; - $existsStmt->close(); - if (!$bookExists) { - $response->getBody()->write(json_encode(['error' => true, 'message' => __('Libro non trovato')])); - return $response->withHeader('Content-Type', 'application/json')->withStatus(404); - } - - $totalCopies = $this->getBookTotalCopies($bookId); - - // Get current and future loans for this book. Approved states always - // hold a copy; a reservation-conversion pending (attivo=0 with a - // copia_id) also holds its copy until pickup confirmation (#157, model - // A-refined). Bare 'pendente' requests with no copy are excluded. - $stmt = $this->db->prepare(" - SELECT data_prestito, data_scadenza, data_restituzione, pickup_deadline, stato, copia_id - FROM prestiti - WHERE libro_id = ? AND ( - (attivo = 1 AND stato IN ('in_corso', 'in_ritardo', 'da_ritirare', 'prenotato')) - OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) - ) - ORDER BY data_prestito - "); - $stmt->bind_param('i', $bookId); - $stmt->execute(); - $loansResult = $stmt->get_result(); - $currentLoans = $loansResult->fetch_all(MYSQLI_ASSOC); - - // Get existing reservations - $stmt = $this->db->prepare(" - SELECT data_inizio_richiesta, data_fine_richiesta, data_scadenza_prenotazione, stato, queue_position, utente_id - FROM prenotazioni - WHERE libro_id = ? AND stato = 'attiva' - ORDER BY queue_position ASC - "); - $stmt->bind_param('i', $bookId); - $stmt->execute(); - $reservationsResult = $stmt->get_result(); - $existingReservations = $reservationsResult->fetch_all(MYSQLI_ASSOC); - - // Calculate availability considering total copies - // Note: For public API, we don't exclude any user - $availability = $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies); - - $response->getBody()->write(json_encode([ - 'success' => true, - 'availability' => $availability, - 'current_loans' => $currentLoans, - 'existing_reservations' => $existingReservations - ])); - return $response->withHeader('Content-Type', 'application/json'); - } + // Rimosso getBookAvailability(): non era instradato da nessuna rotta e il + // suo payload esponeva gli utente_id dei prenotanti (le rotte reali passano + // da getBookAvailabilityData(), che filtra i campi sensibili). private function calculateAvailability($currentLoans, $existingReservations, int $totalCopies, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null) { @@ -110,6 +53,7 @@ private function calculateAvailability($currentLoans, $existingReservations, int // midnight and 2am Rome time, diverging from every web surface. $start = new DateTime($startDate ?: \App\Support\DateHelper::today()); $start->setTime(0, 0, 0); + $today = \App\Support\DateHelper::today(); // Normalize intervals (#157, model A-refined): // approved loans (prenotato, da_ritirare, in_corso, in_ritardo) hold a @@ -139,7 +83,15 @@ private function calculateAvailability($currentLoans, $existingReservations, int // even though they haven't picked it up yet $endDateLoan = $loan['data_scadenza'] ?? (new DateTime($startDateLoan))->add(new DateInterval('P7D'))->format('Y-m-d'); - } elseif ($loanStatus === 'in_ritardo' && empty($loan['data_restituzione'])) { + } elseif ( + empty($loan['data_restituzione']) + && ( + $loanStatus === 'in_ritardo' + || ($loanStatus === 'in_corso' + && !empty($loan['data_scadenza']) + && $loan['data_scadenza'] < $today) + ) + ) { // Overdue and not yet returned: the copy is physically still out and its // original data_scadenza is in the PAST — using it would free the copy on // the availability calendar and let a new request slip in (double-booking). @@ -241,12 +193,6 @@ private function calculateAvailability($currentLoans, $existingReservations, int ]; } - if ($earliestAvailable === null) { - // If all scanned days are busy, pick the first free day after the scanned window - $fallback = (clone $start)->add(new DateInterval("P{$days}D")); - $earliestAvailable = $fallback->format('Y-m-d'); - } - return [ 'total_copies' => $totalCopies, 'unavailable_dates' => array_values(array_unique($unavailableDates)), diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index f97226854..4a711a726 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1310,7 +1310,7 @@ public function updateEventSettings(Request $request, Response $response, mysqli } /** - * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string} + * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, allow_multiple_loans_same_book: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string} */ private function resolveLoansSettings(SettingsRepository $repository): array { @@ -1321,6 +1321,7 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'max_active_loans_per_user' => (int) ($repository->get('loans', 'max_active_loans_per_user', '0') ?? 0), 'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90), 'auto_approve_requests' => $repository->autoApproveLoanRequests(), + 'allow_multiple_loans_same_book' => $repository->allowsMultipleLoansSameBook(), // #360: automatic recall (sollecito) schedule for overdue loans. 'recall_auto_enabled' => (string) ($repository->get('loans', 'recall_auto_enabled', '0') ?? '0') === '1', 'recall_interval_days' => (int) ($repository->get('loans', 'recall_interval_days', '7') ?? 7), @@ -1353,6 +1354,9 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $autoApprove = isset($data['auto_approve_requests']) && is_scalar($data['auto_approve_requests']) && (string) $data['auto_approve_requests'] === '1'; + $allowMultipleLoansSameBook = isset($data['allow_multiple_loans_same_book']) + && is_scalar($data['allow_multiple_loans_same_book']) + && (string) $data['allow_multiple_loans_same_book'] === '1'; // #360: automatic recall schedule. The same clamps are re-applied at // read time by NotificationService::sendLoanRecalls(). $recallEnabled = isset($data['recall_auto_enabled']) @@ -1367,6 +1371,7 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'max_active_loans_per_user', (string) $maxActiveLoans); $repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration); $repository->set('loans', 'auto_approve_requests', $autoApprove ? '1' : '0'); + $repository->set('loans', 'allow_multiple_loans_same_book', $allowMultipleLoansSameBook ? '1' : '0'); $repository->set('loans', 'recall_auto_enabled', $recallEnabled ? '1' : '0'); $repository->set('loans', 'recall_interval_days', (string) $recallInterval); $repository->set('loans', 'recall_max_count', (string) $recallMaxCount); diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index 463fb6dd3..5e0017dd3 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -205,11 +205,15 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re $updateStmt->execute(); $updateStmt->close(); - // If it had a reserved copy, free it and reassign - if ($loan['stato'] === 'prenotato' && $loan['copia_id']) { + // If it had a reserved copy, free it and reassign. Vale anche per un + // 'pendente' promosso dalla coda (attivo=0 con copia_id): prima solo + // il ramo 'prenotato' riassegnava subito e la copia del pendente + // annullato restava in attesa dello sweep di manutenzione. + if ($loan['copia_id'] && in_array((string) $loan['stato'], ['prenotato', 'pendente'], true)) { $copiaId = (int) $loan['copia_id']; - // Update copy status to available (if it was 'prenotato') + // Update copy status to available (if it was 'prenotato'; la copia + // di un pendente promosso resta 'disponibile' e l'UPDATE è no-op) $copyStmt = $db->prepare("UPDATE copie SET stato = 'disponibile' WHERE id = ? AND stato = 'prenotato'"); $copyStmt->bind_param('i', $copiaId); $copyStmt->execute(); @@ -773,6 +777,13 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // Canonical peak-capacity decision (same service as admin create, // approval, renew and audit), excluding this user defensively. $capacity = new \App\Services\CapacityService($db); + // Senza righe in `copie` la coda non può mai convertire (la + // promozione seleziona solo copie fisiche): rifiuta a monte invece + // di accettare una prenotazione che fallirebbe in silenzio per sempre. + if (!$capacity->hasPhysicalCopies($libroId)) { + $db->rollback(); + return $this->back($response, ['reserve_error' => 'not_available']); + } if (!$capacity->hasFreeCapacity($libroId, $start, $end, excludeUserId: $utenteId)) { $db->rollback(); return $this->back($response, ['reserve_error' => 'not_available']); diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index f263cd625..a595c9989 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -744,6 +744,11 @@ public function getAvailableByBookId(int $bookId): array */ public function getAvailableByBookIdForDateRange(int $bookId, string $startDate, string $endDate): array { + // Solo copie fisicamente in sede: una copia 'prestato' è fuori con un + // altro prestito e non è assegnabile, anche quando i predicati per data + // non la vedono. #366 residual: un 'in_corso' scaduto per data ma non + // ancora flippato dal cron blocca come 'in_ritardo' (open-ended). + $today = \App\Support\DateHelper::today(); $stmt = $this->db->prepare(" SELECT c.* FROM copie c @@ -753,13 +758,13 @@ public function getAvailableByBookIdForDateRange(int $bookId, string $startDate, SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' OR (p.stato = 'in_corso' AND p.data_scadenza < ?) OR p.data_scadenza >= ?) AND ((p.attivo = 1 AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL)) ) ORDER BY c.numero_inventario ASC "); - $stmt->bind_param('iss', $bookId, $endDate, $startDate); + $stmt->bind_param('isss', $bookId, $endDate, $today, $startDate); $stmt->execute(); $result = $stmt->get_result(); diff --git a/app/Models/DashboardStats.php b/app/Models/DashboardStats.php index dd650d35d..85ea7c75b 100644 --- a/app/Models/DashboardStats.php +++ b/app/Models/DashboardStats.php @@ -227,20 +227,24 @@ public function calendarEvents(): array JOIN utenti u ON p.utente_id = u.id WHERE (p.attivo = 1 OR p.stato = 'pendente') AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo', 'pendente') - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) AND p.data_prestito <= ? ORDER BY p.data_prestito ASC"; $stmt = $this->db->prepare($loanSql); - $stmt->bind_param('ss', $today, $sixMonthsLater); + $stmt->bind_param('sss', $today, $today, $sixMonthsLater); $stmt->execute(); $res = $stmt->get_result(); while ($row = $res->fetch_assoc()) { + $isOpenEnded = $row['stato'] === 'in_ritardo' + || ($row['stato'] === 'in_corso' && (string) $row['data_scadenza'] < $today); $events[] = [ 'id' => 'loan_' . $row['id'], 'title' => $row['titolo'], 'user' => $row['utente_nome'], 'start' => $row['data_prestito'], - 'end' => $row['stato'] === 'in_ritardo' + 'end' => $isOpenEnded ? max((string) $row['data_scadenza'], $today) : $row['data_scadenza'], 'type' => 'prestito', diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php index 64a370b89..d5dbd4a15 100644 --- a/app/Models/LoanRepository.php +++ b/app/Models/LoanRepository.php @@ -144,9 +144,15 @@ public function getActiveLoanByBook(int $bookId): ?array * registrerebbe come consegnato — quei casi passano dai percorsi dedicati * (annullamento/scadenza ritiro), che gestiscono anche la riassegnazione. * - * @return bool false se il prestito non esiste o non è chiudibile. + * I parametri expected* sono un optimistic identity guard per protocolli + * esterni: se forniti, libro e utente risolti prima della transazione devono + * ancora coincidere con la riga bloccata. In questo modo un CheckIn NCIP non + * può chiudere un prestito riassegnato durante la finestra TOCTOU. + * + * @return bool false se il prestito non esiste, non è chiudibile o non + * corrisponde più all'identità attesa. */ - public function close(int $id): bool + public function close(int $id, ?int $expectedBookId = null, ?int $expectedUserId = null): bool { // ORDINE DI LOCK CANONICO (P3): determina il libro del prestito con una // lettura NON bloccante PRIMA di begin_transaction() (lock-first, MVCC), @@ -190,7 +196,7 @@ public function close(int $id): bool // e ricalcolato il libro sbagliato. In quel caso (rarissimo: il // libro di un prestito non viene riassegnato) abortiamo invece di // operare su dati incoerenti. - $stmt = $this->db->prepare('SELECT libro_id, copia_id, data_scadenza, attivo, stato FROM prestiti WHERE id=? FOR UPDATE'); + $stmt = $this->db->prepare('SELECT libro_id, utente_id, copia_id, data_scadenza, attivo, stato FROM prestiti WHERE id=? FOR UPDATE'); $stmt->bind_param('i', $id); $stmt->execute(); $lockedRow = $stmt->get_result()->fetch_assoc(); @@ -203,6 +209,11 @@ public function close(int $id): bool $this->db->rollback(); return false; } + if (($expectedBookId !== null && (int) $lockedRow['libro_id'] !== $expectedBookId) + || ($expectedUserId !== null && (int) $lockedRow['utente_id'] !== $expectedUserId)) { + $this->db->rollback(); + return false; + } // Guardia di stato (H1): solo un prestito ancora aperto è chiudibile // (vedi docblock). Verificata QUI, sotto lock, così una restituzione diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index cc43db959..ad3e65471 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -97,6 +97,16 @@ public function autoApproveLoanRequests(): bool return ($this->get('loans', 'auto_approve_requests', '0') ?? '0') === '1'; } + /** + * Whether staff workflows may register more than one open loan for the same + * borrower and title when every loan is tied to a distinct physical copy. + * Missing or malformed values deliberately preserve the strict default. + */ + public function allowsMultipleLoansSameBook(): bool + { + return ($this->get('loans', 'allow_multiple_loans_same_book', '0') ?? '0') === '1'; + } + /** * Configured default loan duration. Invalid or missing values retain the * historical 30-day fallback in every loan creation/update path. diff --git a/app/Routes/web.php b/app/Routes/web.php index 394e50882..ff80f30a8 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2232,9 +2232,15 @@ // payload si contraddiceva: occupied_ranges diceva "libero" mentre // first_available/is_available_now (calcolati per-giorno qui sotto) // contavano anche pendenti-con-copia e coda prenotazioni. + $today = \App\Support\DateHelper::today(); $stmt = $db->prepare(" SELECT data_prestito, - CASE WHEN stato = 'in_ritardo' THEN '9999-12-31' ELSE data_scadenza END AS occupied_until, + CASE + WHEN stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + THEN '9999-12-31' + ELSE data_scadenza + END AS occupied_until, stato FROM prestiti WHERE libro_id = ? AND ( @@ -2243,7 +2249,7 @@ ) ORDER BY data_prestito "); - $stmt->bind_param('i', $libroId); + $stmt->bind_param('si', $today, $libroId); $stmt->execute(); $result = $stmt->get_result(); $occupiedRanges = []; @@ -2285,7 +2291,6 @@ // first_available / is_available_now: delega al calcolo per-giorno e per-copia // (AVAIL-001). Il vecchio "giorno dopo la scadenza più lontana" ignorava le // copie multiple, restituendo una data troppo conservativa. - $today = \App\Support\DateHelper::today(); $reservations = new \App\Controllers\ReservationsController($db); $availability = $reservations->getBookAvailabilityData($libroId, $today, 180); if ($availability === null) { @@ -2302,7 +2307,7 @@ 'copie_disponibili' => (int)($book['copie_disponibili'] ?? 0), 'copie_totali' => (int)($availability['total_copies'] ?? ($book['copie_totali'] ?? 0)), 'occupied_ranges' => $occupiedRanges, - 'first_available' => $availability['earliest_available'] ?? $today, + 'first_available' => $availability['earliest_available'] ?? null, 'is_available_now' => $isAvailableNow ]; @@ -2661,7 +2666,7 @@ 'success' => true, 'availability' => [ 'unavailable_dates' => $availability['unavailable_dates'] ?? [], - 'earliest_available' => $availability['earliest_available'] ?? \App\Support\DateHelper::today(), + 'earliest_available' => $availability['earliest_available'] ?? null, 'days' => $availability['days'] ?? [], // F040: true when the excluded user already holds an active // reservation on this book — the picker would otherwise show diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index 6e8bfb2c2..001631f1f 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -79,6 +79,25 @@ public function totalCopies(int $libroId): int return $row !== null ? (int) $row['total'] : 0; } + /** + * True se il libro ha almeno una riga in `copie`. Un libro legacy senza + * copie fisiche registrate supera il gate di capacità tramite il fallback + * copie_totali, ma la promozione della coda seleziona SOLO da `copie`: + * una prenotazione accettata non convertirebbe mai (fallirebbe in silenzio + * a ogni run fino alla scadenza). I gate di CREAZIONE prenotazione usano + * questo check per rifiutare a monte; i prestiti legacy copyless esistenti + * non passano di qui e restano gestiti dal fallback. + */ + public function hasPhysicalCopies(int $libroId): bool + { + $stmt = $this->db->prepare("SELECT 1 FROM copie WHERE libro_id = ? LIMIT 1"); + $stmt->bind_param('i', $libroId); + $stmt->execute(); + $hasRows = $stmt->get_result()->num_rows > 0; + $stmt->close(); + return $hasRows; + } + /** * OCC(b,[s,e]) — the peak simultaneous occupancy over the inclusive interval * [$start,$end], counting HOLDING loans + active reservations. Excludes the @@ -128,6 +147,72 @@ public function hasFreeCapacity( return $occ < $total; } + /** + * Earliest day on or after $from with at least one free capacity unit. + * + * Uses the same canonical HOLDING/reservation intervals as every write + * gate, then sweeps only their boundary events. Open-ended overdue loans + * are clamped to the horizon without a release event, so no guessed return + * date is exposed while a physical copy is still out. + */ + public function firstAvailableDate(int $libroId, string $from): ?string + { + try { + $fromDate = new \DateTimeImmutable($from); + } catch (\Throwable) { + return null; + } + if ($fromDate->format('Y-m-d') !== $from) { + return null; + } + + $total = $this->totalCopies($libroId); + if ($total <= 0) { + return null; + } + + // Keep one day available for finite end+1 events. Any interval clamped + // to this horizon is effectively open-ended for application purposes. + $horizon = '9999-12-30'; + $intervals = array_merge( + $this->holdingLoanIntervals($libroId, $from, $horizon, null, null), + $this->activeReservationIntervals($libroId, $from, $horizon, null, null) + ); + if ($intervals === []) { + return $from; + } + + /** @var array $events */ + $events = []; + foreach ($intervals as [$start, $end]) { + $events[$start] = ($events[$start] ?? 0) + 1; + if ($end < $horizon) { + $release = (new \DateTimeImmutable($end))->modify('+1 day')->format('Y-m-d'); + $events[$release] = ($events[$release] ?? 0) - 1; + } + } + ksort($events, SORT_STRING); + + $occupied = 0; + $cursor = $from; + foreach ($events as $date => $delta) { + // No boundary changes between cursor and date: if capacity was + // already free, cursor is the earliest possible answer. + if ($date > $cursor && $occupied < $total) { + return $cursor; + } + $occupied += $delta; + if ($date >= $from && $occupied < $total) { + return $date; + } + $cursor = $date; + } + + // Remaining occupancy is made exclusively of horizon-clamped, + // open-ended commitments; their physical return date is unknown. + return null; + } + /** * Every day in [$start,$end] that has NO free capacity, computed from a * SINGLE load of the loan/reservation intervals plus an in-memory per-day diff --git a/app/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php index c9dc21b6a..f050487ca 100644 --- a/app/Services/ReservationReassignmentService.php +++ b/app/Services/ReservationReassignmentService.php @@ -127,38 +127,13 @@ private function rollbackIfOwned(bool $ownTransaction): void /** * Riassegna prenotazioni (prestiti con stato='prenotato') a una nuova copia disponibile. * Da chiamare quando viene aggiunta una copia o una copia torna disponibile. + * + * @return bool true se un candidato è stato agganciato alla copia: il + * chiamante può richiamare per servire, con la stessa copia, + * anche gli hold successivi a finestre disgiunte. */ - public function reassignOnNewCopy(int $libroId, int $newCopiaId): void + public function reassignOnNewCopy(int $libroId, int $newCopiaId): bool { - // 1. Trova prenotazioni che sono "bloccate" (assegnate a copie non disponibili o senza copia) - // Ordina per data creazione (FIFO) - // Solo righe GENUINAMENTE bloccate: senza copia, oppure con una copia in - // stato NON prestabile. NON 'c.stato != disponibile' — una copia 'prenotato' - // o 'prestato' per un periodo NON sovrapposto è legittimamente assegnata e - // non va strappata (BUG6/D11). - $stmt = $this->db->prepare(" - SELECT p.id, p.copia_id, p.utente_id, p.data_prestito, p.data_scadenza - FROM prestiti p - LEFT JOIN copie c ON p.copia_id = c.id - WHERE p.libro_id = ? - AND ( (p.attivo = 1 AND p.stato IN ('prenotato', 'da_ritirare')) - OR (p.attivo = 0 AND p.stato = 'pendente' AND p.origine = 'prenotazione') ) - AND ( p.copia_id IS NULL - OR c.stato IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento') ) - ORDER BY p.created_at ASC - LIMIT 1 - "); - $stmt->bind_param('i', $libroId); - $stmt->execute(); - $result = $stmt->get_result(); - $reservation = $result->fetch_assoc(); - $stmt->close(); - - if (!$reservation) { - return; - } - - // 2. Se abbiamo trovato una prenotazione da sbloccare, proviamo ad assegnarla alla nuova copia $ownTransaction = $this->beginTransactionIfNeeded(); try { // CI-SOFT-DELETE-EXEMPT: an existing hold must be released/reassigned even if its book is deleted. @@ -167,58 +142,138 @@ public function reassignOnNewCopy(int $libroId, int $newCopiaId): void $lockBook->execute(); $lockBook->close(); - // The initial lookup was intentionally non-locking to preserve book - // first ordering. Revalidate the chosen hold now, under the book lock. - $lockedReservationStmt = $this->db->prepare(" + // Cheap advisory pre-filter after the canonical book lock: only rows + // that are currently copyless or pinned to an operationally unavailable + // copy enter the locking batch. The authoritative state re-check happens + // below after both prestiti and copie rows have been locked. + // BUG8/D13 parity con activateScheduledLoans: un hold con finestra + // interamente trascorsa (non ancora cullato dal cron) non deve + // ricevere la copia rientrata prima di candidati validi — la + // terrebbe impegnata solo fino alla scadenza del pickup. + $today = \App\Support\DateHelper::today(); + $prefilterStmt = $this->db->prepare(" + SELECT p.id + FROM prestiti p + LEFT JOIN copie c ON p.copia_id = c.id + WHERE p.libro_id = ? + AND ( (p.attivo = 1 AND p.stato IN ('prenotato', 'da_ritirare')) + OR (p.attivo = 0 AND p.stato = 'pendente' AND p.origine = 'prenotazione') ) + AND (p.copia_id IS NULL + OR c.stato IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento')) + AND p.data_scadenza >= ? + ORDER BY p.created_at ASC, p.id ASC + "); + $prefilterStmt->bind_param('is', $libroId, $today); + $prefilterStmt->execute(); + $prefilterResult = $prefilterStmt->get_result(); + $candidateIds = []; + while ($row = $prefilterResult->fetch_assoc()) { + $candidateIds[] = (int) $row['id']; + } + $prefilterStmt->close(); + if ($candidateIds === []) { + $this->rollbackIfOwned($ownTransaction); + return false; + } + + // Lock the pre-filtered prestiti as one deterministic FIFO batch and + // re-assert their lifecycle state. No copy row is locked yet. + $candidatePlaceholders = implode(',', array_fill(0, count($candidateIds), '?')); + $candidateLockStmt = $this->db->prepare(" SELECT id, copia_id, utente_id, data_prestito, data_scadenza FROM prestiti - WHERE id = ? AND libro_id = ? - AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare')) + WHERE libro_id = ? AND id IN ({$candidatePlaceholders}) + AND ( (attivo = 1 AND stato IN ('prenotato', 'da_ritirare')) OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione') ) + AND data_scadenza >= ? + ORDER BY created_at ASC, id ASC FOR UPDATE "); - $lockedReservationStmt->bind_param('ii', $reservation['id'], $libroId); - $lockedReservationStmt->execute(); - $lockedReservation = $lockedReservationStmt->get_result()->fetch_assoc(); - $lockedReservationStmt->close(); - if (!$lockedReservation) { + $candidateParams = array_merge([$libroId], $candidateIds, [$today]); + $candidateTypes = 'i' . str_repeat('i', count($candidateIds)) . 's'; + $candidateLockStmt->bind_param($candidateTypes, ...$candidateParams); + $candidateLockStmt->execute(); + $candidateResult = $candidateLockStmt->get_result(); + $candidates = $candidateResult ? $candidateResult->fetch_all(MYSQLI_ASSOC) : []; + $candidateLockStmt->close(); + if ($candidates === []) { $this->rollbackIfOwned($ownTransaction); - return; + return false; } - $reservation = $lockedReservation; - // Verifica che la nuova copia sia effettivamente disponibile (lock) - $stmt = $this->db->prepare("SELECT id, stato FROM copie WHERE id = ? FOR UPDATE"); - $stmt->bind_param('i', $newCopiaId); - $stmt->execute(); - $copyStatus = $stmt->get_result()->fetch_assoc(); - $stmt->close(); + // One policy-owned current read provides the canonical open-state + // predicate and normalized user/copy identity for every candidate. + // The result answers same-borrower and overlap checks without N+1. + $multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($this->db); + $targetCommitments = $multiplicityPolicy->lockOpenCopyCommitments( + $libroId, + copyId: $newCopiaId + ); + + // Lock the target and every candidate's currently pinned copy in one + // ascending-ID batch. All prestiti locks above are therefore complete + // before the first copie lock (libri -> prestiti -> copie). + $copyIds = [$newCopiaId => true]; + foreach ($candidates as $candidate) { + if ($candidate['copia_id'] !== null) { + $copyIds[(int) $candidate['copia_id']] = true; + } + } + $copyIds = array_keys($copyIds); + sort($copyIds, SORT_NUMERIC); + $copyPlaceholders = implode(',', array_fill(0, count($copyIds), '?')); + $copyLockStmt = $this->db->prepare(" + SELECT id, stato + FROM copie + WHERE libro_id = ? AND id IN ({$copyPlaceholders}) + ORDER BY id ASC + FOR UPDATE + "); + $copyParams = array_merge([$libroId], $copyIds); + $copyTypes = str_repeat('i', count($copyParams)); + $copyLockStmt->bind_param($copyTypes, ...$copyParams); + $copyLockStmt->execute(); + $copyResult = $copyLockStmt->get_result(); + $copiesById = []; + while ($copy = $copyResult->fetch_assoc()) { + $copiesById[(int) $copy['id']] = (string) $copy['stato']; + } + $copyLockStmt->close(); - if (!$copyStatus || !in_array($copyStatus['stato'], ['disponibile', 'prenotato'], true)) { + $targetCopyState = $copiesById[$newCopiaId] ?? null; + if ($targetCopyState === null || !in_array($targetCopyState, ['disponibile', 'prenotato'], true)) { $this->rollbackIfOwned($ownTransaction); - return; + return false; } - // Non riassegnare se la copia target ha un impegno HOLDING sovrapposto - // al periodo della prenotazione: eviterebbe un SIGNAL del trigger di - // overlap che avvelenerebbe la transazione (BUG6/D11). Overlap inclusivo. - $ovl = $this->db->prepare(" - SELECT 1 FROM prestiti - WHERE copia_id = ? AND id <> ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) - AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) - OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) ) - LIMIT 1 - "); - $ovl->bind_param('iiss', $newCopiaId, $reservation['id'], $reservation['data_scadenza'], $reservation['data_prestito']); - $ovl->execute(); - $hasOverlap = (bool) $ovl->get_result()->fetch_row(); - $ovl->close(); - if ($hasOverlap) { - // La nuova copia non è libera per l'intero periodo: lascia la - // prenotazione bloccata, verrà riassegnata da un'altra copia/ritorno. + $nonLendableStates = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento']; + $reservation = null; + foreach ($candidates as $candidate) { + // Authoritative blocked-state re-check from the locked copy batch. + // A row whose original copy became usable is no longer a candidate. + $currentCopyId = $candidate['copia_id'] !== null ? (int) $candidate['copia_id'] : null; + if ($currentCopyId !== null) { + $currentCopyState = $copiesById[$currentCopyId] ?? null; + if ($currentCopyState === null || !in_array($currentCopyState, $nonLendableStates, true)) { + continue; + } + } + + if (!\App\Support\LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + (int) $candidate['id'], + (int) $candidate['utente_id'], + (string) $candidate['data_prestito'], + (string) $candidate['data_scadenza'], + $targetCommitments, + $today + )) { + $reservation = $candidate; + break; + } + } + if ($reservation === null) { $this->rollbackIfOwned($ownTransaction); - return; + return false; } // Aggiorna il prestito/prenotazione con la nuova copia @@ -258,6 +313,8 @@ public function reassignOnNewCopy(int $libroId, int $newCopiaId): void $this->notifyUserCopyAvailable((int) $reservation['id']); } + return true; + } catch (\Throwable $e) { $this->rollbackIfOwned($ownTransaction); // In transazione esterna rilanciamo: altrimenti il proprietario farebbe @@ -271,6 +328,7 @@ public function reassignOnNewCopy(int $libroId, int $newCopiaId): void 'copia_id' => $newCopiaId, 'error' => $e->getMessage() ]); + return false; } } @@ -283,16 +341,21 @@ public function reassignOnCopyLost(int $copiaId): void // Trova un impegno HOLDING "futuro" su questa copia da riassegnare. Include // 'da_ritirare' (ritiro in attesa) oltre a 'prenotato' (BUG7b/D12): perdere // la copia di un ritiro in attesa deve riassegnarlo, non lasciarlo bloccato. + // La finestra deve essere ancora valida (data_scadenza >= oggi): riassegnare + // un hold già scaduto ne riattiverebbe di fatto la finestra su una nuova + // copia — quello va lasciato alla scadenza/pulizia, non alla riassegnazione. + $today = \App\Support\DateHelper::today(); $stmt = $this->db->prepare(" SELECT id, libro_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE copia_id = ? + AND data_scadenza >= ? AND ( (attivo = 1 AND stato IN ('prenotato', 'da_ritirare')) OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione') ) ORDER BY data_prestito ASC, id ASC LIMIT 1 "); - $stmt->bind_param('i', $copiaId); + $stmt->bind_param('is', $copiaId, $today); $stmt->execute(); $reservation = $stmt->get_result()->fetch_assoc(); $stmt->close(); @@ -318,8 +381,10 @@ public function reassignOnCopyLost(int $copiaId): void $libroId, $excludedCopies, $reservationId, + (int) $reservation['utente_id'], $resStart, - $resEnd + $resEnd, + $today ); if (!$nextCopyId) { @@ -338,14 +403,15 @@ public function reassignOnCopyLost(int $copiaId): void $lockBook->close(); $lockReservation = $this->db->prepare(" - SELECT id, copia_id, data_prestito, data_scadenza + SELECT id, copia_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE id = ? AND libro_id = ? AND copia_id = ? + AND data_scadenza >= ? AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare')) OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione') ) FOR UPDATE "); - $lockReservation->bind_param('iii', $reservationId, $libroId, $copiaId); + $lockReservation->bind_param('iiis', $reservationId, $libroId, $copiaId, $today); $lockReservation->execute(); $currentReservation = $lockReservation->get_result()->fetch_assoc(); $lockReservation->close(); @@ -356,6 +422,14 @@ public function reassignOnCopyLost(int $copiaId): void $resStart = (string) $currentReservation['data_prestito']; $resEnd = (string) $currentReservation['data_scadenza']; + $committedCopyIds = (new \App\Support\LoanMultiplicityPolicy($this->db)) + ->committedCopyIds($libroId, (int) $currentReservation['utente_id'], $reservationId); + if (in_array($nextCopyId, $committedCopyIds, true)) { + $this->rollbackIfOwned($ownTransaction); + $excludedCopies[] = $nextCopyId; + continue; + } + // Lock della nuova copia e verifica stato (race condition protection) $stmt = $this->db->prepare("SELECT id, stato FROM copie WHERE id = ? FOR UPDATE"); $stmt->bind_param('i', $nextCopyId); @@ -378,12 +452,15 @@ public function reassignOnCopyLost(int $copiaId): void $ovl = $this->db->prepare(" SELECT 1 FROM prestiti WHERE copia_id = ? AND id <> ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND data_prestito <= ? + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) ) LIMIT 1 "); - $ovl->bind_param('iiss', $nextCopyId, $reservationId, $resEnd, $resStart); + $ovl->bind_param('iisss', $nextCopyId, $reservationId, $resEnd, $today, $resStart); $ovl->execute(); $hasOverlap = (bool) $ovl->get_result()->fetch_row(); $ovl->close(); @@ -512,7 +589,7 @@ private function handleNoCopyAvailable(int $reservationId): void * Quando un libro viene restituito, controlla se ci sono prenotazioni in attesa * e assegna la copia restituita alla prossima prenotazione. */ - public function reassignOnReturn(int $copiaId): void + public function reassignOnReturn(int $copiaId): bool { // 1. Trova il libro $stmt = $this->db->prepare("SELECT libro_id FROM copie WHERE id = ?"); @@ -521,14 +598,26 @@ public function reassignOnReturn(int $copiaId): void $res = $stmt->get_result()->fetch_assoc(); $stmt->close(); - if (!$res) - return; + if (!$res) { + return false; + } $libroId = (int) $res['libro_id']; // 2. Cerca la prenotazione più vecchia SENZA copia assegnata (o assegnata a copia non disp) // Nota: reassignOnNewCopy fa esattamente questo logicamente: prende una copia disponibile (questa) - // e cerca chi ne ha bisogno. - $this->reassignOnNewCopy($libroId, $copiaId); + // e cerca chi ne ha bisogno. Ripeti finché aggancia: la stessa copia può + // servire più hold a finestre DISGIUNTE (una sola assegnazione per + // chiamata lasciava i successivi copyless fino allo sweep del cron). + // Ogni aggancio riduce i candidati copyless, quindi il loop termina; + // il bound è solo una cintura di sicurezza. + $assignedAny = false; + for ($i = 0; $i < 50; $i++) { + if (!$this->reassignOnNewCopy($libroId, $copiaId)) { + break; + } + $assignedAny = true; + } + return $assignedAny; } /** @@ -540,8 +629,10 @@ private function findAvailableCopyExcluding( int $libroId, array $excludeCopiaIds, int $reservationId, + int $userId, string $startDate, - string $endDate + string $endDate, + string $applicationToday ): ?int { $sql = " @@ -549,21 +640,35 @@ private function findAvailableCopyExcluding( FROM copie c WHERE c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND NOT EXISTS ( + SELECT 1 + FROM prestiti own + WHERE own.copia_id = c.id + AND own.libro_id = ? + AND own.utente_id = ? + AND own.id <> ? + AND ( + (own.attivo = 0 AND own.stato = 'pendente') + OR (own.attivo = 1 AND own.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + ) + ) AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.id <> ? AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) AND ( (p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL) ) ) "; - $params = [$libroId, $reservationId, $endDate, $startDate]; - $types = 'iiss'; + $params = [$libroId, $libroId, $userId, $reservationId, $reservationId, $endDate, $applicationToday, $startDate]; + $types = 'iiiiisss'; if (!empty($excludeCopiaIds)) { $placeholders = implode(',', array_fill(0, count($excludeCopiaIds), '?')); @@ -630,13 +735,15 @@ private function notifyUserCopyAvailable(int $prestitoId): void $bookLink = book_url(['id' => $data['libro_id'], 'titolo' => $data['libro_titolo'] ?? '', 'autore' => $author['nome'] ?? '']); + // #360 parity: date nella lingua del destinatario, non d/m/Y hard-coded. + $recipientLocale = $this->notificationService->resolveRecipientLocale((string) $data['email']); $variables = [ 'utente_nome' => $data['utente_nome'] ?: __('Utente'), 'libro_titolo' => $data['libro_titolo'] ?: __('Libro'), 'libro_autore' => $author['nome'] ?? __('Autore sconosciuto'), 'libro_isbn' => $isbn, - 'data_inizio' => $data['data_prestito'] ? date('d/m/Y', strtotime($data['data_prestito'])) : '', - 'data_fine' => $data['data_scadenza'] ? date('d/m/Y', strtotime($data['data_scadenza'])) : '', + 'data_inizio' => $data['data_prestito'] ? $this->notificationService->formatEmailDate((string) $data['data_prestito'], false, $recipientLocale) : '', + 'data_fine' => $data['data_scadenza'] ? $this->notificationService->formatEmailDate((string) $data['data_scadenza'], false, $recipientLocale) : '', 'book_url' => absoluteUrl($bookLink), 'profile_url' => absoluteUrl(RouteTranslator::route('profile')) ]; diff --git a/app/Support/ContentSecurityPolicy.php b/app/Support/ContentSecurityPolicy.php index 7a2c1a252..0555e62fd 100644 --- a/app/Support/ContentSecurityPolicy.php +++ b/app/Support/ContentSecurityPolicy.php @@ -11,14 +11,26 @@ */ final class ContentSecurityPolicy { + private const NONCE_PATTERN = '/^(?:[a-f0-9]{8}-){3}[a-f0-9]{8}$/D'; + + /** + * Generate a 128-bit per-response nonce without long decimal runs. + * + * Like the CSRF token (see Csrf::generateToken()), an unbroken hexadecimal + * nonce occasionally contains a 13-16 digit Luhn-valid window that ZAP's + * PII scanner mistakes for a payment-card number — the nonce is stamped on + * every inline script/style of every page. Grouping the lossless hex + * encoding caps decimal runs at eight characters while staying within the + * CSP base64url nonce alphabet, which permits '-'. + */ public static function createNonce(): string { - return bin2hex(random_bytes(16)); + return implode('-', str_split(bin2hex(random_bytes(16)), 8)); } public static function header(string $nonce, bool $upgradeInsecureRequests = false): string { - if (!preg_match('/^[a-f0-9]{32}$/', $nonce)) { + if (!preg_match(self::NONCE_PATTERN, $nonce)) { throw new \InvalidArgumentException('Invalid CSP nonce'); } @@ -51,7 +63,7 @@ public static function header(string $nonce, bool $upgradeInsecureRequests = fal public static function addNonceAttributes(string $html, string $nonce): string { - if (!preg_match('/^[a-f0-9]{32}$/', $nonce)) { + if (!preg_match(self::NONCE_PATTERN, $nonce)) { throw new \InvalidArgumentException('Invalid CSP nonce'); } diff --git a/app/Support/Csrf.php b/app/Support/Csrf.php index 09ae001d9..f5edea750 100644 --- a/app/Support/Csrf.php +++ b/app/Support/Csrf.php @@ -31,7 +31,7 @@ public static function ensureToken(): string } if ($needsRegeneration) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + $_SESSION['csrf_token'] = self::generateToken(); $_SESSION['csrf_token_time'] = time(); } @@ -101,8 +101,20 @@ public static function validateWithReason(?string $token): array */ public static function regenerate(): void { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + $_SESSION['csrf_token'] = self::generateToken(); $_SESSION['csrf_token_time'] = time(); } -} + /** + * Generate a 256-bit token without long decimal runs. + * + * ZAP's PII scanner can mistake a random 13-16 digit run inside an + * unbroken hexadecimal token for a payment-card number. Grouping the + * lossless hexadecimal encoding keeps the full entropy while ensuring + * that no decimal run can exceed eight characters. + */ + private static function generateToken(): string + { + return implode('-', str_split(bin2hex(random_bytes(32)), 8)); + } +} diff --git a/app/Support/DataIntegrity.php b/app/Support/DataIntegrity.php index 725f54331..129ccc404 100644 --- a/app/Support/DataIntegrity.php +++ b/app/Support/DataIntegrity.php @@ -824,6 +824,7 @@ private function findOverbookedCirculationPeriods(): array { // the auditor MUST count it — otherwise it would bless an over-capacity // waitlist that the creation/promotion gates already reject. $eventsByBook = []; + $applicationToday = DateHelper::today(); $stmt = $this->db->prepare(" SELECT libro_id, 'prestito' AS source_type, id AS source_id, DATE(data_prestito) AS start_date, @@ -833,7 +834,12 @@ private function findOverbookedCirculationPeriods(): array { -- stays a 4-digit 'YYYY-MM-DD' string: '10000-01-01' would sort -- BEFORE every real date under ksort's lexicographic order -- ('1' < '2'), zeroing the overdue occupancy in the peak scan. - WHEN attivo = 1 AND stato = 'in_ritardo' THEN '9999-12-30' + -- A date-overdue `in_corso` row is the same unreturned + -- physical copy before maintenance flips its state (#366). + WHEN attivo = 1 AND ( + stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + ) THEN '9999-12-30' ELSE data_scadenza END) AS end_date FROM prestiti @@ -844,6 +850,7 @@ private function findOverbookedCirculationPeriods(): array { AND data_prestito IS NOT NULL AND data_scadenza IS NOT NULL "); + $stmt->bind_param('s', $applicationToday); $stmt->execute(); $intervalsResult = $stmt->get_result(); diff --git a/app/Support/DateHelper.php b/app/Support/DateHelper.php index 8474b219b..e39aa7d48 100644 --- a/app/Support/DateHelper.php +++ b/app/Support/DateHelper.php @@ -6,16 +6,36 @@ class DateHelper { + /** + * Expose the application-local date to integrity triggers on this connection. + * + * MySQL's CURRENT_DATE() follows the database session timezone, which can + * differ from app.timezone near midnight. Circulation triggers therefore + * read this connection-scoped value and use CURRENT_DATE() only for direct + * SQL clients that have not initialized the application session. + */ + public static function synchronizeDatabaseSession(\mysqli $db): void + { + $today = self::today(); + $stmt = $db->prepare('SET @pinakes_application_date = ?'); + try { + $stmt->bind_param('s', $today); + $stmt->execute(); + } finally { + $stmt->close(); + } + } + /** * Today's date (Y-m-d) in the application's configured timezone. * * PHP's default timezone is often UTC while the library runs in a local * zone (default Europe/Rome). Near midnight the two disagree by a day, so a - * reservation/loan dated "today" (stored via MySQL CURDATE() in the local - * zone) would look like a future date to a raw date('Y-m-d') (UTC) - * comparison and have its eligibility deferred by 24h. Every "is this date - * eligible today?" check in the loan/reservation pipeline must compute - * "today" through here so PHP and MySQL agree on the day boundary. + * reservation/loan dated "today" would look like a future date to a raw + * date('Y-m-d') (UTC) comparison and have its eligibility deferred by 24h. + * Every "is this date eligible today?" check in the loan/reservation + * pipeline must compute "today" here; synchronizeDatabaseSession() publishes + * that same day to the database integrity triggers. */ public static function today(): string { diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index 15e5714c6..e7e122e40 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -106,4 +106,27 @@ public static function promotableReservationWhere(string $alias): string AND DATE({$alias}.data_scadenza_prenotazione) >= ?) )"; } + + /** + * Shared SQL predicate for users that are eligible to receive a loan. + * + * This mirrors checkUser() and is intended for queue selection, where + * filtering before LIMIT avoids head-of-line starvation from suspended or + * expired patrons. A final checkUser() under the caller's transaction still + * provides the authoritative race-safe decision. + * + * Requires ONE positional placeholder bound to the application date. + * $alias is a fixed table alias supplied by application code. + */ + public static function eligibleUserWhere(string $alias): string + { + return "( + {$alias}.stato = 'attivo' + AND ( + {$alias}.tipo_utente NOT IN ('standard', 'premium') + OR {$alias}.data_scadenza_tessera IS NULL + OR {$alias}.data_scadenza_tessera >= ? + ) + )"; + } } diff --git a/app/Support/LoanMultiplicityPolicy.php b/app/Support/LoanMultiplicityPolicy.php new file mode 100644 index 000000000..628fea4d6 --- /dev/null +++ b/app/Support/LoanMultiplicityPolicy.php @@ -0,0 +1,238 @@ +settings = $settings ?? new SettingsRepository($db); + } + + public function isEnabled(): bool + { + return $this->settings->allowsMultipleLoansSameBook(); + } + + /** + * Whether another open loan blocks the proposed borrower/title assignment. + * + * The relaxed rule is available only when the caller guarantees that the + * operation will finish with a physical copy. Otherwise the historical, + * title-level uniqueness rule is retained. + */ + public function hasBlockingLoan( + int $bookId, + int $userId, + bool $operationWillBindCopy, + ?int $excludeLoanId = null + ): bool { + $strict = !$operationWillBindCopy || !$this->isEnabled(); + $excludeSql = $excludeLoanId !== null ? ' AND id <> ?' : ''; + // A promoted reservation remains a queue commitment until pickup, even + // when it already has a physical copy. The relaxed rule applies only to + // physical loans; it must not create a second queue position for the + // same borrower/title. Once picked up (`in_corso`/`in_ritardo`), the row + // is a copy-bound physical loan and can coexist like a direct checkout. + $activeBlockingPredicate = $strict + ? '' + : " AND (copia_id IS NULL OR (origine = 'prenotazione' AND stato IN ('prenotato', 'da_ritirare')))"; + + $stmt = $this->db->prepare(" + SELECT id + FROM prestiti + WHERE libro_id = ? AND utente_id = ?{$excludeSql} AND ( + (attivo = 0 AND stato = 'pendente') + OR ( + attivo = 1{$activeBlockingPredicate} + AND stato IN (" . self::OPEN_ACTIVE_STATES_SQL . ") + ) + ) + LIMIT 1 + FOR UPDATE + "); + + if ($excludeLoanId !== null) { + $stmt->bind_param('iii', $bookId, $userId, $excludeLoanId); + } else { + $stmt->bind_param('ii', $bookId, $userId); + } + $stmt->execute(); + $hasBlockingLoan = $stmt->get_result()->num_rows > 0; + $stmt->close(); + + return $hasBlockingLoan; + } + + /** + * Physical copies already committed by this borrower for the same title. + * + * The list ignores date overlap on purpose: two open same-borrower/title rows + * represent two physical items even when their scheduled windows do not + * overlap. It remains authoritative after the setting is switched off so + * lifecycle jobs cannot corrupt duplicates that were validly created while + * enabled. Callers use it to reject or skip an already committed copy. + * + * @return list + */ + public function committedCopyIds( + int $bookId, + int $userId, + ?int $excludeLoanId = null + ): array { + $copyIds = []; + foreach ($this->lockOpenCopyCommitments( + $bookId, + userId: $userId, + excludeLoanId: $excludeLoanId + ) as $commitment) { + $copyIds[$commitment['copyId']] = true; + } + + $copyIds = array_keys($copyIds); + sort($copyIds, SORT_NUMERIC); + return $copyIds; + } + + /** + * Lock and normalize copy-bound open commitments in one deterministic batch. + * + * At least one selector is required. The copy selector is used by lifecycle + * allocation; the user selector powers committedCopyIds(). Keeping both on + * this API gives every caller the same open-state predicate and typed identity. + * Callers must already hold the canonical book lock. + * + * @return list + */ + public function lockOpenCopyCommitments( + int $bookId, + ?int $copyId = null, + ?int $userId = null, + ?int $excludeLoanId = null + ): array { + if ($copyId === null && $userId === null) { + throw new \InvalidArgumentException('A copy or user selector is required.'); + } + + $where = [ + 'libro_id = ?', + 'copia_id IS NOT NULL', + self::OPEN_COMMITMENT_PREDICATE, + ]; + $types = 'i'; + $params = [$bookId]; + + if ($copyId !== null) { + $where[] = 'copia_id = ?'; + $types .= 'i'; + $params[] = $copyId; + } + if ($userId !== null) { + $where[] = 'utente_id = ?'; + $types .= 'i'; + $params[] = $userId; + } + if ($excludeLoanId !== null) { + $where[] = 'id <> ?'; + $types .= 'i'; + $params[] = $excludeLoanId; + } + + $stmt = $this->db->prepare(" + SELECT id, copia_id, utente_id, data_prestito, data_scadenza, stato + FROM prestiti + WHERE " . implode(' AND ', $where) . " + ORDER BY id ASC + FOR UPDATE + "); + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + $commitments = []; + while ($row = $result->fetch_assoc()) { + $commitments[] = [ + 'loanId' => (int) $row['id'], + 'copyId' => (int) $row['copia_id'], + 'userId' => (int) $row['utente_id'], + 'startDate' => (string) $row['data_prestito'], + 'endDate' => (string) $row['data_scadenza'], + 'state' => (string) $row['stato'], + ]; + } + $stmt->close(); + + return $commitments; + } + + /** + * Pure conflict check for assigning one candidate to a locked copy batch. + * + * Same-borrower identity is stronger than dates: two open rows for a title + * must represent distinct physical items. Other borrowers block only when + * their inclusive date windows overlap; overdue commitments are open-ended. + * The application date is injectable so callers can use the same timezone + * boundary as their SQL predicates and tests can remain deterministic. + * + * @param list $commitments + */ + public static function candidateConflictsWithOpenCommitments( + int $candidateLoanId, + int $candidateUserId, + string $candidateStartDate, + string $candidateEndDate, + array $commitments, + ?string $applicationToday = null + ): bool { + $applicationToday ??= DateHelper::today(); + + foreach ($commitments as $commitment) { + if ($commitment['loanId'] === $candidateLoanId) { + continue; + } + + if ($commitment['userId'] === $candidateUserId) { + return true; + } + + $startsBeforeCandidateEnds = $commitment['startDate'] <= $candidateEndDate; + $isOpenEnded = $commitment['state'] === 'in_ritardo' + || ($commitment['state'] === 'in_corso' && $commitment['endDate'] < $applicationToday); + $endsAfterCandidateStarts = $isOpenEnded + || $commitment['endDate'] >= $candidateStartDate; + if ($startsBeforeCandidateEnds && $endsAfterCandidateStarts) { + return true; + } + } + + return false; + } +} diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index ded5dca0f..190d4016a 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -43,7 +43,7 @@ public function __construct(mysqli $db) * non basta, due admin in sessioni diverse eseguirebbero entrambi runAll(). * * @param int $cooldownMinutes Minimum minutes between runs (default: 60) - * @return array{skipped?: bool, reason?: string, scheduled_loans_activated?: int, invalid_ready_pickups_repaired?: int, expired_waitlist_reservations?: int, reservations_converted?: int, expired_reservations?: int, expired_pickups?: int, overdue_loans_updated?: int, expiration_warnings?: int, overdue_notifications?: int, wishlist_notifications?: int, reservation_notifications_retried?: int, ics_generated?: bool, errors?: array} Results or skip status + * @return array{skipped?: bool, reason?: string, scheduled_loans_activated?: int, invalid_ready_pickups_repaired?: int, expired_waitlist_reservations?: int, reservations_converted?: int, expired_reservations?: int, expired_pickups?: int, overdue_loans_updated?: int, expiration_warnings?: int, overdue_notifications?: int, wishlist_notifications?: int, reservation_notifications_retried?: int, pickup_notifications_retried?: int, ics_generated?: bool, errors?: array} Results or skip status */ public function runIfNeeded(int $cooldownMinutes = 60): array { @@ -101,7 +101,7 @@ public function runIfNeeded(int $cooldownMinutes = 60): array * overdue loan updates, expired pickups, notifications, and ICS calendar generation. * Each task is wrapped in try-catch to prevent failures from blocking others. * - * @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, ics_generated: bool, errors: array} Results for each maintenance task + * @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, ics_generated: bool, errors: array} Results for each maintenance task */ public function runAll(): array { @@ -117,6 +117,7 @@ public function runAll(): array 'overdue_notifications' => 0, 'wishlist_notifications' => 0, 'reservation_notifications_retried' => 0, + 'pickup_notifications_retried' => 0, 'ics_generated' => false, 'errors' => [] ]; @@ -218,6 +219,16 @@ public function runAll(): array SecureLogger::error(__('MaintenanceService errore recupero notifiche prenotazione'), ['error' => $e->getMessage()]); } + // Recupero delle email "pronto al ritiro" fallite (stesso razionale M4): + // il claim su pickup_notification_sent vive in NotificationService, + // quindi lo sweep non può mai duplicare un invio riuscito. + try { + $results['pickup_notifications_retried'] = (new NotificationService($this->db))->retryUnsentPickupNotifications(); + } catch (\Throwable $e) { + $results['errors'][] = 'retryUnsentPickupNotifications: ' . $e->getMessage(); + SecureLogger::error(__('MaintenanceService errore recupero notifiche ritiro'), ['error' => $e->getMessage()]); + } + // Best-effort plugin push dispatch (Mobile API): fire AFTER the email // reminders on the same cron pass. Plugins hook 'mobile_api.dispatch_push' // to deliver native push for the same events. No-op when no plugin is @@ -264,9 +275,46 @@ public function runAll(): array SecureLogger::error(__('MaintenanceService errore hook post-run'), ['error' => $e->getMessage()]); } + // Aggiorna il marker di cooldown cross-sessione anche quando runAll() è + // chiamato direttamente (i cron entrypoint non passano da runIfNeeded): + // senza, l'admin che logga un minuto dopo il cron vince il claim e + // riesegue l'intera manutenzione sincrona nella request di login. + try { + $completedAt = (string) time(); + $stmt = $this->db->prepare(" + INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('maintenance', 'last_run', ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) + "); + if ($stmt) { + $stmt->bind_param('s', $completedAt); + $stmt->execute(); + $stmt->close(); + } + } catch (\Throwable $e) { + // Tabella assente (installazione in corso): fail-open, solo cooldown perso. + SecureLogger::warning(__('MaintenanceService aggiornamento marker cooldown fallito'), ['error' => $e->getMessage()]); + } + return $results; } + /** + * Garantisce la colonna prestiti.pickup_notification_sent sugli upgrade + * (stesso pattern runtime-add di NotificationService::addNotificationColumns): + * gli UPDATE di attivazione/riparazione la referenziano e girerebbero prima + * che NotificationService abbia mai eseguito l'ALTER. + */ + private ?bool $pickupNotificationColumnAvailable = null; + + private function ensurePickupNotificationColumn(): bool + { + if ($this->pickupNotificationColumnAvailable !== null) { + return $this->pickupNotificationColumnAvailable; + } + return $this->pickupNotificationColumnAvailable = PickupNotificationSchema::ensure($this->db); + } + /** * Generate ICS calendar file for loans and reservations * @@ -331,6 +379,11 @@ public function runNotifications(): array */ public function repairInvalidReadyPickups(): int { + $pickupColumnReset = $this->ensurePickupNotificationColumn() + ? ",\n pickup_notification_sent = 0, + pickup_notification_claim_token = NULL, + pickup_notification_last_attempt_at = NULL" + : ''; $stmt = $this->db->prepare(" SELECT id, libro_id FROM prestiti @@ -435,7 +488,7 @@ public function repairInvalidReadyPickups(): int $repair = $this->db->prepare(" UPDATE prestiti - SET stato = 'prenotato', pickup_deadline = NULL, copia_id = NULL + SET stato = 'prenotato', pickup_deadline = NULL, copia_id = NULL{$pickupColumnReset} WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 "); $repair->bind_param('i', $loanId); @@ -516,6 +569,12 @@ public function repairInvalidReadyPickups(): int */ public function activateScheduledLoans(): int { + $pickupColumnReset = $this->ensurePickupNotificationColumn() + ? ",\n pickup_notification_sent = 0, + pickup_notification_claim_token = NULL, + pickup_notification_last_attempt_at = NULL" + : ''; + // "Oggi" nel timezone applicativo come parametro bound (M9): CURDATE() // dipende dalla session timezone del client DB, che differiva tra cron // (UTC forzato) e web (nessuna impostazione). @@ -525,7 +584,7 @@ public function activateScheduledLoans(): int // guard (BUG8/D13): never promote a reservation whose whole window is already // past into 'da_ritirare' — its expiry cron culls it instead. $stmt = $this->db->prepare(" - SELECT id, copia_id, libro_id, data_scadenza FROM prestiti + SELECT id, copia_id, libro_id, utente_id, data_scadenza FROM prestiti WHERE stato = 'prenotato' AND data_prestito <= ? AND data_scadenza >= ? @@ -573,7 +632,7 @@ public function activateScheduledLoans(): int } $lockLoan = $this->db->prepare(" - SELECT id, copia_id, libro_id, data_prestito, data_scadenza + SELECT id, copia_id, libro_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE id = ? AND stato = 'prenotato' AND attivo = 1 AND data_prestito <= ? AND data_scadenza >= ? @@ -588,6 +647,11 @@ public function activateScheduledLoans(): int continue; } $loan = $lockedLoan; + $committedCopyIds = (new LoanMultiplicityPolicy($this->db))->committedCopyIds( + $bookId, + (int) $loan['utente_id'], + $loanId + ); // #366 guard: a reservation may only become 'da_ritirare' (and get // the pickup-ready email) when a physical copy is genuinely free @@ -632,6 +696,12 @@ public function activateScheduledLoans(): int // promoting them with copia_id=NULL only moves the failure to // confirmPickup(), which correctly refuses a copy-less loan. $copiaId = !empty($loan['copia_id']) ? (int) $loan['copia_id'] : 0; + if ($copiaId > 0 && in_array($copiaId, $committedCopyIds, true)) { + // Repair legacy/reassigned rows that share a copy with another + // open loan for this borrower/title: activation must choose a + // distinct physical item, regardless of date overlap. + $copiaId = 0; + } if ($copiaId > 0) { $copyStmt = $this->db->prepare('SELECT stato FROM copie WHERE id = ? AND libro_id = ? FOR UPDATE'); $copyStmt->bind_param('ii', $copiaId, $bookId); @@ -658,22 +728,39 @@ public function activateScheduledLoans(): int $copyConflict->close(); if (!in_array($copyState, ['disponibile', 'prenotato'], true) || $copyHeld) { - $this->db->rollback(); - SecureLogger::info(__('Attivazione prestito rinviata: copia assegnata non in sede'), [ + // The originally pinned copy may still be out while a + // sibling copy is already back on the shelf (#366). + // Fall through to the same allocator used by legacy + // copyless rows instead of leaving the reservation + // stuck on the unavailable physical item forever. + SecureLogger::info(__('Attivazione prestito: copia assegnata non in sede, ricerca alternativa'), [ 'prestito_id' => $loanId, 'libro_id' => $bookId, 'copia_id' => $copiaId, 'copia_stato' => $copyState, 'copia_impegnata' => $copyHeld, ]); - continue; + $copiaId = 0; } - } else { + } + if ($copiaId <= 0) { $freeCopy = $this->db->prepare(" SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND NOT EXISTS ( + SELECT 1 + FROM prestiti own + WHERE own.copia_id = c.id + AND own.libro_id = ? + AND own.utente_id = ? + AND own.id <> ? + AND ( + (own.attivo = 0 AND own.stato = 'pendente') + OR (own.attivo = 1 AND own.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + ) + ) AND NOT EXISTS ( SELECT 1 FROM prestiti p @@ -687,7 +774,16 @@ public function activateScheduledLoans(): int LIMIT 1 FOR UPDATE "); - $freeCopy->bind_param('iiss', $bookId, $loanId, $loan['data_scadenza'], $loan['data_prestito']); + $freeCopy->bind_param( + 'iiiiiss', + $bookId, + $bookId, + $loan['utente_id'], + $loanId, + $loanId, + $loan['data_scadenza'], + $loan['data_prestito'] + ); $freeCopy->execute(); $freeCopyRow = $freeCopy->get_result()->fetch_assoc(); $freeCopy->close(); @@ -715,7 +811,7 @@ public function activateScheduledLoans(): int // State guard: only update if still in 'prenotato' state (prevents race with confirmPickup) $updateStmt = $this->db->prepare(" UPDATE prestiti - SET stato = 'da_ritirare', pickup_deadline = ?, copia_id = ? + SET stato = 'da_ritirare', pickup_deadline = ?, copia_id = ?{$pickupColumnReset} WHERE id = ? AND stato = 'prenotato' AND attivo = 1 AND data_scadenza >= ? "); $updateStmt->bind_param('siis', $pickupDeadline, $copiaId, $loan['id'], $today); @@ -795,6 +891,7 @@ public function processScheduledReservations(): int JOIN utenti u ON p.utente_id = u.id WHERE p.stato = 'attiva' AND " . \App\Support\LoanEligibility::promotableReservationWhere('p') . " + AND " . \App\Support\LoanEligibility::eligibleUserWhere('u') . " ORDER BY p.libro_id, p.queue_position ASC "); @@ -802,7 +899,7 @@ public function processScheduledReservations(): int throw new \RuntimeException('Failed to prepare scheduled reservations query'); } - $stmt->bind_param('ss', $today, $today); + $stmt->bind_param('sss', $today, $today, $today); $stmt->execute(); $result = $stmt->get_result(); $reservations = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; @@ -1340,6 +1437,7 @@ public static function onAdminLogin(int $_userId, array $userData, $_request): v } $db->set_charset($cfg['charset']); + DateHelper::synchronizeDatabaseSession($db); } $service = new self($db); diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 0d6667fca..fec4ea955 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -37,7 +37,7 @@ public function __construct(mysqli $db) { * historical behaviour (installation locale) * @return string Formatted date */ - private function formatEmailDate(string $dateString, bool $includeTime = false, ?string $locale = null): string + public function formatEmailDate(string $dateString, bool $includeTime = false, ?string $locale = null): string { $timestamp = strtotime($dateString); if ($timestamp === false) { @@ -74,7 +74,7 @@ private function formatEmailDate(string $dateString, bool $includeTime = false, * the app itself: the same value already drives the recipient's UI * language (login, profile, language switcher keep it up to date). */ - private function resolveRecipientLocale(string $email): string + public function resolveRecipientLocale(string $email): string { $email = trim($email); $fallback = I18n::getInstallationLocale(); @@ -528,6 +528,26 @@ public function sendLoanExpirationWarnings(): int { // due-today loan, the number of days otherwise (matches the in-app // notification wording below). $daysRemaining = (int)$loan['giorni_rimasti']; + $notificationMessage = $daysRemaining === 0 + ? sprintf(__('"%s" prestato a %s scade oggi'), $loan['libro_titolo'], $loan['utente_nome']) + : sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], $daysRemaining); + + // Utente senza email: niente da inviare né da ritentare. Senza + // questo ramo il claim veniva revertito a ogni run (retry SMTP a + // vuoto per sempre) e la notifica in-app non nasceva mai. Il flag + // resta claimato e lo staff viene avvisato in-app comunque. + if (trim((string) $loan['utente_email']) === '') { + $this->createNotification( + 'general', + __('Prestito in scadenza'), + $notificationMessage, + '/admin/loans', + (int)$loan['id'] + ); + SecureLogger::info("Expiration warning for loan {$loan['id']}: user has no email, in-app notice only"); + continue; + } + $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], @@ -539,9 +559,6 @@ public function sendLoanExpirationWarnings(): int { if ($emailSent) { // Create in-app notification for expiring loan - $notificationMessage = $daysRemaining === 0 - ? sprintf(__('"%s" prestato a %s scade oggi'), $loan['libro_titolo'], $loan['utente_nome']) - : sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], $daysRemaining); $this->createNotification( 'general', __('Prestito in scadenza'), @@ -626,6 +643,23 @@ public function sendOverdueLoanNotifications(): int { continue; } + // Utente senza email: il claim resta (nulla da ritentare — prima + // veniva revertito a ogni run, retry SMTP a vuoto per sempre) e + // soprattutto gli ADMIN vengono avvisati comunque: erano dentro + // if ($emailSent), quindi per i prestatari senza indirizzo il + // ritardo non arrivava mai né via email admin né in-app. + if (trim((string) $loan['utente_email']) === '') { + $this->notifyAdminsOverdue((int)$loan['id']); + $this->notifyOverdueLoanInApp( + (int)$loan['id'], + $loan['utente_nome'], + $loan['libro_titolo'], + (int)$loan['giorni_ritardo'] + ); + SecureLogger::info("Overdue notification for loan {$loan['id']}: user has no email, admins notified only"); + continue; + } + $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], @@ -1020,6 +1054,7 @@ public function notifyWishlistBookAvailability(int $bookId): int { AND u.stato = 'attivo' AND w.notified = 0 AND l.copie_disponibili > 0 + AND u.email IS NOT NULL AND TRIM(u.email) <> '' GROUP BY w.id, w.utente_id, u.nome, u.cognome, u.email, l.titolo, l.isbn13, l.isbn10 "); $stmt->bind_param('i', $bookId); @@ -1184,59 +1219,26 @@ public function hasActualAvailableCopy(int $bookId): bool { * @return string|null Date in Y-m-d format, or null if no loans/reservations */ public function getNextAvailabilityDate(int $bookId): ?string { - // App-timezone "today" (M9), come hasActualAvailableCopy(). $today = DateHelper::today(); - - // First check if book is already available - if ($this->hasActualAvailableCopy($bookId)) { - return $today; - } - - // Find the earliest end date among active loans (approved states only) - $loanStmt = $this->db->prepare(" - SELECT MIN(data_scadenza) as earliest_end - FROM prestiti - WHERE libro_id = ? AND attivo = 1 - AND stato IN ('in_corso', 'in_ritardo', 'da_ritirare', 'prenotato') - AND data_scadenza >= ? - "); - $loanStmt->bind_param('is', $bookId, $today); - $loanStmt->execute(); - $loanResult = $loanStmt->get_result()->fetch_assoc(); - $earliestLoanEnd = $loanResult['earliest_end'] ?? null; - $loanStmt->close(); - - // Find the earliest end date among active reservations - $resStmt = $this->db->prepare(" - SELECT MIN(COALESCE(data_fine_richiesta, DATE(data_scadenza_prenotazione), data_inizio_richiesta)) as earliest_end - FROM prenotazioni - WHERE libro_id = ? AND stato = 'attiva' - AND COALESCE(data_inizio_richiesta, DATE(data_scadenza_prenotazione)) IS NOT NULL - AND COALESCE(data_fine_richiesta, DATE(data_scadenza_prenotazione), data_inizio_richiesta) >= ? - "); - $resStmt->bind_param('is', $bookId, $today); - $resStmt->execute(); - $resResult = $resStmt->get_result()->fetch_assoc(); - $earliestResEnd = $resResult['earliest_end'] ?? null; - $resStmt->close(); - - // Return the earliest of the two (the day after it ends, the book becomes available) - $dates = array_filter([$earliestLoanEnd, $earliestResEnd]); - if (empty($dates)) { - return null; - } - - $earliestEnd = min($dates); - // Return the day after the earliest end date - return date('Y-m-d', strtotime($earliestEnd . ' +1 day')); + return (new \App\Services\CapacityService($this->db)) + ->firstAvailableDate($bookId, $today); } /** * Aggiunge colonne per tracking notifiche se non esistono */ - private function addNotificationColumns(): void { + /** + * Public: anche i chiamanti esterni che aggiornano i flag di notifica + * direttamente (es. il claim pickup in LoanApprovalController) devono + * poter garantire le colonne prima dell'UPDATE sugli install legacy. + * + * @return bool true se le colonne sono garantite; false se l'inizializzazione + * è fallita (install legacy con ALTER negato) — in quel caso i + * chiamanti NON devono eseguire UPDATE sui flag di notifica. + */ + public function addNotificationColumns(): bool { if ($this->notificationColumnsEnsured) { - return; + return true; } try { // Check if columns exist @@ -1250,6 +1252,15 @@ private function addNotificationColumns(): void { $this->db->query("ALTER TABLE prestiti ADD COLUMN overdue_notification_sent BOOLEAN DEFAULT 0"); } + // Claim/retry dell'email "pronto al ritiro": schema + backfill + // resumable are centralized so controller, cron and maintenance + // cannot drift or run DDL inside a circulation transaction. + // Un fallimento qui NON deve saltare la creazione delle colonne + // recall qui sotto: sendLoanRecalls() le interroga ignorando il + // valore di ritorno, e un early-return lascerebbe i solleciti + // automatici silenziosamente a zero per una causa non correlata. + $pickupSchemaReady = PickupNotificationSchema::ensure($this->db); + // #360: recall (sollecito) tracking — how many recalls went out and // when the last one did, so automatic recalls can repeat at the // configured interval instead of being one-shot like @@ -1264,12 +1275,17 @@ private function addNotificationColumns(): void { $this->db->query("ALTER TABLE prestiti ADD COLUMN last_recall_at DATETIME NULL DEFAULT NULL"); } + if (!$pickupSchemaReady) { + return false; + } + // Only memoize once the checks completed without throwing, so a // transient failure retries on the next call. $this->notificationColumnsEnsured = true; } catch (\Throwable $e) { SecureLogger::error("Failed to add notification columns: " . $e->getMessage()); } + return $this->notificationColumnsEnsured; } /** @@ -1490,7 +1506,14 @@ public function sendLoanRejectedNotificationDirect( * Invia notifica quando un prestito è pronto per il ritiro (stato da_ritirare) */ public function sendPickupReadyNotification(int $loanId): bool { + $claimToken = null; + try { + // A restricted legacy DB may not be able to self-heal before the + // updater runs. In that case preserve the historical one-shot send + // instead of losing the email because the claim column is absent. + $claimSchemaAvailable = PickupNotificationSchema::ensure($this->db); + $stmt = $this->db->prepare(" SELECT p.*, l.titolo as libro_titolo, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email @@ -1509,6 +1532,60 @@ public function sendPickupReadyNotification(int $loanId): bool { } $stmt->close(); + // Claim atomico come warning/overdue: marca l'avviso PRIMA di + // inviare, così un doppio trigger non duplica l'email e un + // fallimento SMTP viene ritentato dallo sweep + // retryUnsentPickupNotifications() invece di perdersi per sempre. + // Solo per righe 'da_ritirare' (l'unico stato che ha un ritiro da + // annunciare); per stati diversi mantiene il comportamento storico. + $isReadyPickup = ($loan['stato'] ?? '') === 'da_ritirare' && (int) ($loan['attivo'] ?? 0) === 1; + if ($isReadyPickup && $claimSchemaAvailable) { + $claimToken = bin2hex(random_bytes(16)); + $claimWindow = PickupNotificationSchema::claimLeaseWindow(); + $attemptedAt = $claimWindow['attemptedAt']; + $staleBefore = $claimWindow['staleBefore']; + $recipientUserId = (int) $loan['utente_id']; + $claimStmt = $this->db->prepare(" + UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ? AND utente_id = ? + AND attivo = 1 AND stato = 'da_ritirare' + AND ( + pickup_notification_sent IS NULL + OR pickup_notification_sent = 0 + OR ( + pickup_notification_sent = 1 + AND pickup_notification_claim_token IS NOT NULL + AND pickup_notification_last_attempt_at < ? + ) + ) + "); + $claimStmt->bind_param('ssiis', $claimToken, $attemptedAt, $loanId, $recipientUserId, $staleBefore); + $claimStmt->execute(); + $claimAcquired = $claimStmt->affected_rows === 1; + $claimStmt->close(); + if (!$claimAcquired) { + $claimToken = null; + // Già annunciato/claimato, oppure il destinatario è cambiato + // dopo la lettura. In quest'ultimo caso il reset effettuato + // dalla riassegnazione lascia la riga al retry successivo. + return false; + } + } + + // Utente senza email: il claim resta (nulla da ritentare), niente churn. + if (trim((string) $loan['utente_email']) === '') { + if ($claimToken !== null) { + $ownedToken = $claimToken; + $claimToken = null; + $this->finalizePickupClaim($loanId, $ownedToken); + } + SecureLogger::info("Pickup ready notification for loan {$loanId}: user has no email, skipped"); + return false; + } + // Calculate number of days for loan $startDate = new \DateTime($loan['data_prestito']); $endDate = new \DateTime($loan['data_scadenza']); @@ -1529,14 +1606,137 @@ public function sendPickupReadyNotification(int $loanId): bool { 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale) ]; - return $this->sendWithRetry($loan['utente_email'], 'loan_pickup_ready', $variables); + $emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_pickup_ready', $variables); + + if ($emailSent) { + if ($claimToken !== null) { + $ownedToken = $claimToken; + // Once delivery succeeded, never let a cleanup failure re-arm + // the email. Relinquish local ownership before cleanup. + $claimToken = null; + $this->finalizePickupClaim($loanId, $ownedToken); + } + } elseif ($claimToken !== null) { + // Il claim è certamente riuscito (il ramo !claimAcquired esce): + // revert del flag così lo sweep di manutenzione ritenta al + // prossimo run (stesso pattern di warning/overdue). + $ownedToken = $claimToken; + $claimToken = null; + $this->releasePickupClaim($loanId, $ownedToken); + SecureLogger::warning("Failed to send pickup ready notification for loan {$loanId} after retries, flag reverted"); + } + + return $emailSent; } catch (\Throwable $e) { + if ($claimToken !== null) { + try { + $ownedToken = $claimToken; + $claimToken = null; + $this->releasePickupClaim($loanId, $ownedToken); + } catch (\Throwable $revertError) { + SecureLogger::error("Failed to release pickup notification claim for loan {$loanId}: " . $revertError->getMessage()); + } + } SecureLogger::error("Failed to send pickup ready notification: " . $e->getMessage()); return false; } } + private function releasePickupClaim(int $loanId, string $claimToken): void + { + $revertStmt = $this->db->prepare(" + UPDATE prestiti + SET pickup_notification_sent = 0, + pickup_notification_claim_token = NULL + WHERE id = ? AND pickup_notification_claim_token = ? + "); + $revertStmt->bind_param('is', $loanId, $claimToken); + $revertStmt->execute(); + $revertStmt->close(); + } + + private function finalizePickupClaim(int $loanId, string $claimToken): void + { + $finalizeStmt = $this->db->prepare(" + UPDATE prestiti + SET pickup_notification_claim_token = NULL + WHERE id = ? AND pickup_notification_claim_token = ? + "); + $finalizeStmt->bind_param('is', $loanId, $claimToken); + $finalizeStmt->execute(); + $finalizeStmt->close(); + } + + /** + * Recupero delle email "pronto al ritiro" il cui invio è fallito + * (stato ancora 'da_ritirare', pickup_notification_sent = 0), oppure claim + * rimasti orfani oltre la lease. Chiamato dallo sweep di manutenzione; il + * token vive in sendPickupReadyNotification() e limita la consegna a un + * proprietario alla volta. Dopo un crash il protocollo è at-least-once: + * non può sapere se SMTP abbia accettato il messaggio prima della morte del + * worker, ma privilegia il recupero rispetto alla perdita silenziosa. + */ + public function retryUnsentPickupNotifications(): int { + $sentCount = 0; + try { + if (!PickupNotificationSchema::ensure($this->db)) { + return 0; + } + $today = DateHelper::today(); + $staleBefore = PickupNotificationSchema::claimLeaseWindow()['staleBefore']; + + // Solo ritiri ancora validi (deadline non passata: quelli scaduti li + // culla checkExpiredPickups) e utenti con un indirizzo email. + // Il JOIN su libri rispecchia sendPickupReadyNotification(): senza, + // un ritiro il cui titolo è stato archiviato (soft-delete) verrebbe + // selezionato qui ma scartato PRIMA del claim dall'invio — nessun + // last_attempt_at scritto, riga sempre in testa all'ORDER BY, e con + // 20 righe così il LIMIT si satura e nessun ritiro sano viene più + // notificato. + $stmt = $this->db->prepare(" + SELECT p.id + FROM prestiti p + JOIN utenti u ON p.utente_id = u.id + JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL + WHERE p.attivo = 1 AND p.stato = 'da_ritirare' + AND ( + p.pickup_notification_sent IS NULL + OR p.pickup_notification_sent = 0 + OR ( + p.pickup_notification_sent = 1 + AND p.pickup_notification_claim_token IS NOT NULL + AND p.pickup_notification_last_attempt_at < ? + ) + ) + AND (p.pickup_deadline IS NULL OR p.pickup_deadline >= ?) + AND u.email IS NOT NULL AND TRIM(u.email) <> '' + ORDER BY p.pickup_notification_last_attempt_at IS NULL DESC, + p.pickup_notification_last_attempt_at ASC, + p.id ASC + LIMIT 20 + "); + $stmt->bind_param('ss', $staleBefore, $today); + $stmt->execute(); + $result = $stmt->get_result(); + $loanIds = []; + while ($row = $result->fetch_assoc()) { + $loanIds[] = (int) $row['id']; + } + $stmt->close(); + + foreach ($loanIds as $loanId) { + if ($this->sendPickupReadyNotification($loanId)) { + $sentCount++; + } + } + } catch (\Throwable $e) { + SecureLogger::error("Failed to retry unsent pickup notifications: " . $e->getMessage()); + } + + return $sentCount; + } + /** * Invia notifica quando la scadenza del ritiro è passata (prestito scaduto) */ diff --git a/app/Support/OneTimeFormToken.php b/app/Support/OneTimeFormToken.php new file mode 100644 index 000000000..d4dced2e5 --- /dev/null +++ b/app/Support/OneTimeFormToken.php @@ -0,0 +1,110 @@ + */ + private static function scopeTokens(string $scope): array + { + $all = $_SESSION[self::SESSION_KEY] ?? []; + if (!is_array($all) || !isset($all[$scope]) || !is_array($all[$scope])) { + return []; + } + + $tokens = []; + foreach ($all[$scope] as $token => $issuedAt) { + if (is_string($token) && is_int($issuedAt)) { + $tokens[$token] = $issuedAt; + } + } + return $tokens; + } + + /** @param array $tokens */ + private static function storeScopeTokens(string $scope, array $tokens): void + { + if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { + $_SESSION[self::SESSION_KEY] = []; + } + if ($tokens === []) { + unset($_SESSION[self::SESSION_KEY][$scope]); + return; + } + $_SESSION[self::SESSION_KEY][$scope] = $tokens; + } + + /** + * @param array $tokens + * @return array + */ + private static function prune(array $tokens, int $now): array + { + $cutoff = $now - self::TTL_SECONDS; + return array_filter( + $tokens, + static fn (int $issuedAt): bool => $issuedAt >= $cutoff && $issuedAt <= $now + 60 + ); + } + + private static function assertScope(string $scope): void + { + if ($scope === '' || strlen($scope) > 80 || !preg_match('/^[a-z0-9._-]+$/D', $scope)) { + throw new \InvalidArgumentException('Invalid one-time form token scope.'); + } + } +} diff --git a/app/Support/PickupNotificationSchema.php b/app/Support/PickupNotificationSchema.php new file mode 100644 index 000000000..818d39bcd --- /dev/null +++ b/app/Support/PickupNotificationSchema.php @@ -0,0 +1,213 @@ + $now->format('Y-m-d H:i:s'), + 'staleBefore' => $now->modify('-' . self::CLAIM_LEASE_SECONDS . ' seconds')->format('Y-m-d H:i:s'), + ]; + } + + /** + * A missing timestamp on a non-empty token fails closed. Normal claims + * always persist both values atomically, so this branch only protects a + * malformed/partially migrated row from being reassigned mid-delivery. + */ + public static function isClaimLive(?string $token, ?string $attemptedAt, ?string $staleBefore = null): bool + { + if ($token === null || $token === '') { + return false; + } + if ($attemptedAt === null || $attemptedAt === '') { + return true; + } + + $staleBefore ??= self::claimLeaseWindow()['staleBefore']; + return $attemptedAt >= $staleBefore; + } + + /** + * Ensure all columns used by the claim/retry protocol are available. + * Must be called before begin_transaction() by mutation paths. + */ + public static function ensure(\mysqli $db): bool + { + try { + $columnNames = self::columnNames($db); + $marker = self::markerValue($db); + + if ($marker === null) { + // If the original sent flag already exists, its installer or a + // prior self-heal already chose the historical cohort. Add + // newer protocol columns without reclassifying legitimate + // sent=0 retry rows. + $markerValue = 'done'; + if (!isset($columnNames['pickup_notification_sent'])) { + $markerValue = 'pending'; + } + self::insertMarkerIfAbsent($db, $markerValue); + $marker = self::markerValue($db); + if ($marker === null) { + throw new \RuntimeException('pickup notification backfill marker was not persisted'); + } + } + + $legacyDefaultPending = $marker === 'pending' || preg_match('/^pending:\d+$/D', $marker) === 1; + self::addMissingColumns($db, $columnNames, $legacyDefaultPending); + + if ($legacyDefaultPending) { + // ADD DEFAULT 1 materializes the safe historical value without + // firing UPDATE triggers. Flip only the default for rows + // created after the upgrade; existing values remain 1. + $db->query( + "ALTER TABLE prestiti + MODIFY COLUMN pickup_notification_sent TINYINT(1) DEFAULT 0 + COMMENT 'claim/retry flag for the ready-for-pickup email'" + ); + + $finish = $db->prepare( + "UPDATE system_settings + SET setting_value = 'done' + WHERE category = ? AND setting_key = ? AND setting_value = ?" + ); + $category = self::MARKER_CATEGORY; + $key = self::MARKER_KEY; + $finish->bind_param('sss', $category, $key, $marker); + $finish->execute(); + $finish->close(); + } + + return self::allColumnsExist(self::columnNames($db)); + } catch (\Throwable $e) { + SecureLogger::error('Failed to ensure pickup notification schema: ' . $e->getMessage()); + return false; + } + } + + /** @return array */ + private static function columnNames(\mysqli $db): array + { + $result = $db->query( + "SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'prestiti' + AND COLUMN_NAME IN ( + 'pickup_notification_sent', + 'pickup_notification_claim_token', + 'pickup_notification_last_attempt_at' + )" + ); + $columns = []; + while ($row = $result->fetch_assoc()) { + $columns[(string) $row['COLUMN_NAME']] = true; + } + $result->free(); + return $columns; + } + + /** @param array $columns */ + private static function allColumnsExist(array $columns): bool + { + return isset( + $columns['pickup_notification_sent'], + $columns['pickup_notification_claim_token'], + $columns['pickup_notification_last_attempt_at'] + ); + } + + /** @param array $columns */ + private static function addMissingColumns(\mysqli $db, array $columns, bool $legacyDefaultPending): void + { + if (!isset($columns['pickup_notification_sent'])) { + $historicalDefault = $legacyDefaultPending ? 1 : 0; + self::tryAddColumn( + $db, + "ALTER TABLE prestiti + ADD COLUMN pickup_notification_sent TINYINT(1) DEFAULT {$historicalDefault} + COMMENT 'claim/retry flag for the ready-for-pickup email'" + ); + } + if (!isset($columns['pickup_notification_claim_token'])) { + self::tryAddColumn($db, 'ALTER TABLE prestiti ADD COLUMN pickup_notification_claim_token CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL DEFAULT NULL'); + } + if (!isset($columns['pickup_notification_last_attempt_at'])) { + self::tryAddColumn($db, 'ALTER TABLE prestiti ADD COLUMN pickup_notification_last_attempt_at DATETIME NULL DEFAULT NULL'); + } + } + + /** + * ensure() corre da web, cron e manutenzione insieme: due processi possono + * superare entrambi il controllo isset() e tentare lo stesso ADD COLUMN. + * Il perdente riceve 1060 (Duplicate column name) ma lo schema risultante + * è quello atteso — assorbi SOLO quell'errore e lascia che il controllo + * finale allColumnsExist() in ensure() decida l'esito complessivo. + */ + private static function tryAddColumn(\mysqli $db, string $sql): void + { + try { + $db->query($sql); + } catch (\Throwable $e) { + if ((int) $db->errno !== 1060) { + throw $e; + } + } + } + + private static function markerValue(\mysqli $db): ?string + { + $stmt = $db->prepare( + 'SELECT setting_value FROM system_settings WHERE category = ? AND setting_key = ? LIMIT 1' + ); + $category = self::MARKER_CATEGORY; + $key = self::MARKER_KEY; + $stmt->bind_param('ss', $category, $key); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row === null ? null : (string) $row['setting_value']; + } + + private static function insertMarkerIfAbsent(\mysqli $db, string $value): void + { + $stmt = $db->prepare( + "INSERT IGNORE INTO system_settings + (category, setting_key, setting_value, description) + VALUES (?, ?, ?, 'Resumable state for the 0.7.64 pickup notification schema')" + ); + $category = self::MARKER_CATEGORY; + $key = self::MARKER_KEY; + $stmt->bind_param('sss', $category, $key, $value); + $stmt->execute(); + $stmt->close(); + } +} diff --git a/app/Views/layout.php b/app/Views/layout.php index ab16090f4..15a668108 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -91,6 +91,25 @@ margin-top: 8px; } } + /* Notifications dropdown: on phones the panel is anchored to the (right-hand) + bell button, so a near-full-width panel overflowed the right edge and was + clipped. Pin it to the viewport instead and centre it within the screen. + Kept as explicit CSS (not Tailwind classes) because main.css is a purged + JIT build and would not include new utility classes without a rebuild. + Above the md breakpoint the Tailwind classes (absolute, right-0) apply. */ + @media (max-width: 767px) { + #notifications-dropdown { + position: fixed; + left: 1rem; + right: 1rem; + top: 4rem; + width: auto; + max-width: 28rem; + margin-left: auto; + margin-right: auto; + transform: none; + } + }

+ + +

diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index 18f99308f..0f2ce96b9 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -16,6 +16,8 @@ $meUserId = (int) ($meUserId ?? 0); $meUserName = (string) ($meUserName ?? ''); $defaultLoanDays = max(1, (int) ($defaultLoanDays ?? 30)); +$allowMultipleLoansSameBook = (bool) ($allowMultipleLoansSameBook ?? false); +$loanSubmissionToken = (string) ($loanSubmissionToken ?? ''); $csrf = Csrf::ensureToken(); // Get locale from session (same as frontend/layout.php) @@ -76,6 +78,9 @@ case 'invalid_date_format': echo __('Errore: formato data non valido. Inserisci le date nel formato YYYY-MM-DD.'); break; + case 'expired_window': + echo __('La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo'); + break; case 'no_copies_available': echo __('Tutte le copie di questo libro hanno già un prestito attivo o prenotato. Attendi che una copia venga restituita.'); break; @@ -97,6 +102,9 @@ case 'copy_not_available': echo __('La copia indicata non è disponibile per il periodo richiesto.'); break; + case 'duplicate_submission': + echo __("La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare."); + break; default: echo __('Errore durante la creazione del prestito.'); } @@ -117,8 +125,11 @@ class="ml-auto inline-flex items-center px-3 py-2 bg-red-600 hover:bg-red-700 te
-
+ +
@@ -176,6 +187,11 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te

+ +

+ +

+
@@ -249,7 +265,7 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" + dati condivisi mantenuti; il codice copia viene sempre azzerato. --> @@ -321,8 +337,32 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" // values now being entered. Remove it on the first form edit (including a // scanner-generated input/change event) and clean the stale URL flags so a // refresh cannot bring the old notice or PDF download back. - const loanForm = document.querySelector('form[action$="/admin/loans/create"]'); + // Id stabile invece del selettore sull'action: il markup non dipende + // così dal percorso dell'URL. + const loanForm = document.getElementById('loan-create-form'); const loanCreatedAlert = document.getElementById('loan_created_alert'); + + // Guard anti doppio-submit: con la modalità multi-copia attiva e il campo + // codice vuoto, un replay identico del form auto-assegnerebbe un'ALTRA + // copia (il dup-check stretto non blocca più). Disabilita i bottoni al + // primo submit; il timeout lascia serializzare il valore del bottone + // cliccato (save_and_new) prima del disable. + if (loanForm) { + let loanSubmitInFlight = false; + loanForm.addEventListener('submit', function (event) { + if (loanSubmitInFlight) { + event.preventDefault(); + return; + } + loanSubmitInFlight = true; + setTimeout(function () { + loanForm.querySelectorAll('button[type="submit"]').forEach(function (btn) { + btn.disabled = true; + btn.classList.add('opacity-60', 'cursor-not-allowed'); + }); + }, 0); + }); + } if (loanForm && loanCreatedAlert) { const clearCreatedAlert = function() { if (loanCreatedAlert.isConnected) loanCreatedAlert.remove(); @@ -957,6 +997,13 @@ function resolve() { }); } setupCopyCodeResolver(); + + // In the batch workflow, return the operator directly to the scanner field + // after a successful save. The selected title is retained server-side. + if (loanForm && loanCreatedAlert && loanForm.dataset.multipleCopyMode === '1') { + const nextCopyCode = document.getElementById('copy_code'); + if (nextCopyCode) nextCopyCode.focus(); + } }); diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index 9ed259bfd..925593aa3 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -72,6 +72,9 @@ // La parità (prestito a giornata singola) è lecita: solo il range invertito è rifiutato. echo __('Errore: la data di scadenza non può essere precedente alla data di prestito.'); break; + case 'expired_window': + echo __('Errore: la nuova data di scadenza non può essere nel passato.'); + break; case 'invalid_date_format': echo __('Errore: formato data non valido. Inserisci le date nel formato YYYY-MM-DD.'); break; @@ -89,6 +92,9 @@ case 'loan_not_closable': echo __('Prestito non trovato o non chiudibile.'); break; + case 'concurrent_retry': + echo __('Un\'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.'); + break; case 'no_copies_available': // #336: dire solo "nessuna copia" era fuorviante — il vero motivo // è un conflitto con un altro impegno nel periodo richiesto. diff --git a/app/Views/prestiti/restituito_prestito.php b/app/Views/prestiti/restituito_prestito.php index 24fc1fea2..7b4a7d6c2 100644 --- a/app/Views/prestiti/restituito_prestito.php +++ b/app/Views/prestiti/restituito_prestito.php @@ -45,6 +45,7 @@ echo match ($_GET['error']) { 'invalid_status' => __('Stato prestito non valido.'), 'update_failed' => __('Si è verificato un errore durante l\'aggiornamento del prestito.'), + 'concurrent_retry' => __('Un\'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.'), default => __('Impossibile completare l\'operazione. Riprova più tardi.') }; ?> diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index 9c31f9f0d..67e8371f7 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -252,6 +252,45 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr + +
+
+

+ + +

+

+
+
+ +
+ +
+
+
+
+
+
+
+ +

+
+ +
+
+
+
diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index fc3dbf110..51a36cbae 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -35,9 +35,20 @@ 'standard' => __('Standard') ]; +$genderLabels = [ + 'M' => __('Maschio'), + 'F' => __('Femmina'), + 'Altro' => __('Altro'), +]; + +$sessoKey = trim((string)$sesso); +$sessoLabel = $genderLabels[$sessoKey] ?? $sessoKey; + $display = static function (?string $value, string $placeholder = '—'): string { $value = trim((string)$value); - return $value !== '' ? HtmlHelper::e($value) : $placeholder; + return $value !== '' + ? htmlspecialchars(HtmlHelper::decode($value), ENT_QUOTES, 'UTF-8') + : $placeholder; }; ?> @@ -147,7 +158,7 @@
-
+
diff --git a/config/container.php b/config/container.php index d295c402b..603e43d44 100644 --- a/config/container.php +++ b/config/container.php @@ -119,6 +119,9 @@ $mysqli->set_charset($cfg['charset']); // Ensure consistent collation for all queries (use config charset, not hardcoded) $mysqli->query("SET NAMES '" . $mysqli->real_escape_string($cfg['charset']) . "' COLLATE 'utf8mb4_unicode_ci'"); + // Give database integrity triggers the same authoritative day used + // by the PHP circulation layer (app.timezone). + \App\Support\DateHelper::synchronizeDatabaseSession($mysqli); return $mysqli; diff --git a/cron/automatic-notifications.php b/cron/automatic-notifications.php index 76a98294a..ed0324d7c 100644 --- a/cron/automatic-notifications.php +++ b/cron/automatic-notifications.php @@ -22,6 +22,13 @@ use App\Controllers\ReservationManager; use App\Support\HookManager; +// Difesa in profondità: sotto Apache la .htaccess root instrada tutto su +// public/, ma su nginx (che la ignora) o con una docroot mal configurata +// questo file sarebbe un trigger web non autenticato del batch di notifiche. +if (php_sapi_name() !== 'cli') { + die("This script can only be run from the command line."); +} + // ============================================================ // PROCESS LOCK - Prevent concurrent cron executions // ============================================================ @@ -55,11 +62,13 @@ fwrite($lockHandle, (string)getmypid()); fflush($lockHandle); -// Register shutdown function to release lock and clean up -register_shutdown_function(function () use ($lockHandle, $lockFile) { +// Register shutdown function to release lock. MAI unlink dopo l'unlock +// (stessa scelta di scripts/maintenance.php): B può aver già aperto lo stesso +// inode e lockarlo dopo il nostro rilascio; se A poi lo cancella, C ricrea il +// file e locka il NUOVO inode → B e C girerebbero in parallelo. +register_shutdown_function(function () use ($lockHandle) { flock($lockHandle, LOCK_UN); fclose($lockHandle); - @unlink($lockFile); }); // ============================================================ @@ -106,11 +115,10 @@ function logMessage(string $message): void { } $db->set_charset($cfg['charset']); + \App\Support\DateHelper::synchronizeDatabaseSession($db); - // Niente SET SESSION time_zone (M9): la coerenza sul giorno è garantita dai - // parametri PHP calcolati con DateHelper nel timezone applicativo; il web non - // imposta alcuna session timezone, quindi forzare UTC nel solo cron creava - // due regimi diversi per gli stessi confronti di data. + // Niente SET SESSION time_zone (M9): DateHelper espone ai trigger il giorno + // applicativo senza alterare la timezone MySQL dell'intera connessione. // Bootstrap I18n con il locale di installazione, come fa public/index.php per // il web (H4): in CLI il locale resterebbe il default statico it_IT e le email @@ -158,7 +166,17 @@ function logMessage(string $message): void { logMessage("WARNING: retryUnsentReservationNotifications failed: " . $e->getMessage()); } - $totalSent = $results['expiration_warnings'] + $results['overdue_notifications'] + ($results['loan_recalls'] ?? 0) + $results['wishlist_notifications'] + $retriedNotifications; + // Retry pickup-ready notices on the same hourly cadence. The persisted + // claim makes this safe alongside the daily full-maintenance sweep. + $retriedPickupNotifications = 0; + try { + $retriedPickupNotifications = $notificationService->retryUnsentPickupNotifications(); + logMessage("- Pickup-ready notifications retried: " . $retriedPickupNotifications); + } catch (Throwable $e) { + logMessage("WARNING: retryUnsentPickupNotifications failed: " . $e->getMessage()); + } + + $totalSent = $results['expiration_warnings'] + $results['overdue_notifications'] + ($results['loan_recalls'] ?? 0) + $results['wishlist_notifications'] + $retriedNotifications + $retriedPickupNotifications; logMessage("Total emails sent: {$totalSent}"); // Dispatch native mobile push on the same hourly pass as email reminders. diff --git a/cron/full-maintenance.php b/cron/full-maintenance.php index ecc70d1de..480cfe2bf 100644 --- a/cron/full-maintenance.php +++ b/cron/full-maintenance.php @@ -24,6 +24,13 @@ use Dotenv\Dotenv; use App\Support\MaintenanceService; +// Difesa in profondità: sotto Apache la .htaccess root instrada tutto su +// public/, ma su nginx (che la ignora) o con una docroot mal configurata +// questo file sarebbe un trigger web non autenticato del batch di manutenzione. +if (php_sapi_name() !== 'cli') { + die("This script can only be run from the command line."); +} + // ============================================================ // PROCESS LOCK - Prevent concurrent cron executions // ============================================================ @@ -57,12 +64,13 @@ fwrite($lockHandle, (string)getmypid()); fflush($lockHandle); -// Register shutdown function to release lock and clean up -register_shutdown_function(function () use ($lockHandle, $lockFile) { +// Register shutdown function to release lock. MAI unlink dopo l'unlock +// (stessa scelta di scripts/maintenance.php): B può aver già aperto lo stesso +// inode e lockarlo dopo il nostro rilascio; se A poi lo cancella, C ricrea il +// file e locka il NUOVO inode → B e C girerebbero in parallelo. +register_shutdown_function(function () use ($lockHandle) { flock($lockHandle, LOCK_UN); fclose($lockHandle); - // nosemgrep: php.lang.security.unlink-use.unlink-use -- $lockFile is a fixed internal path (storage/cache/full-maintenance.lock), not user input - @unlink($lockFile); }); // ============================================================ @@ -109,11 +117,10 @@ function logMessage(string $message): void { } $db->set_charset($cfg['charset']); + \App\Support\DateHelper::synchronizeDatabaseSession($db); - // Niente SET SESSION time_zone (M9): la coerenza sul giorno è garantita dai - // parametri PHP calcolati con DateHelper nel timezone applicativo; il web non - // imposta alcuna session timezone, quindi forzare UTC nel solo cron creava - // due regimi diversi per gli stessi confronti di data. + // Niente SET SESSION time_zone (M9): DateHelper espone ai trigger il giorno + // applicativo senza alterare la timezone MySQL dell'intera connessione. // Bootstrap I18n con il locale di installazione, come fa public/index.php per // il web (H4): in CLI il locale resterebbe il default statico it_IT e le email @@ -153,6 +160,7 @@ function logMessage(string $message): void { logMessage(" - Overdue notifications: " . $results['overdue_notifications']); logMessage(" - Wishlist notifications: " . $results['wishlist_notifications']); logMessage(" - Reservation availability notifications retried: " . $results['reservation_notifications_retried']); + logMessage(" - Pickup-ready notifications retried: " . $results['pickup_notifications_retried']); if (!empty($results['errors'])) { logMessage("Errors encountered during maintenance:"); diff --git a/docs/settings.MD b/docs/settings.MD index a90d4df72..518977d02 100644 --- a/docs/settings.MD +++ b/docs/settings.MD @@ -231,12 +231,15 @@ Imposti le regole globali dei prestiti (categoria setting `loans`): | Campo (`name`) | Significato | Default | Clamp lato server | |---|---|---|---| | `loan_duration_days` | Durata standard del prestito (giorni) | 30 | 1 … 3650 (10 anni) | -| `pickup_expiry_days` | Giorni entro cui ritirare un prestito approvato prima della scadenza ritiro | 3 | 1 … 365 | +| `pickup_expiry_days` | Giorni entro cui ritirare un prestito approvato prima della scadenza ritiro | 3 | 1 … 30 | | `max_renewals` | Numero massimo di rinnovi (0 = illimitato) | 3 | 0 … 100 | | `max_active_loans_per_user` | Massimo prestiti attivi per utente (0 = illimitato) | 0 | 0 … 1000 | +| `allow_multiple_loans_same_book` | Consente allo stesso utente più prestiti attivi dello stesso libro su copie fisiche distinte | 0 (disattivato) | Toggle 0 / 1 | > **Importante**: il server **ricalcola e blocca (clamp)** ogni valore entro i limiti indicati a prescindere dagli attributi `min`/`max` del form (che sono solo indicativi). Un POST manipolato non può quindi impostare durate o rinnovi assurdi. +Il toggle **Consenti più prestiti dello stesso titolo solo su copie fisiche distinte** è disattivato per impostazione predefinita: il normale flusso di biblioteca continua quindi a permettere un solo prestito o una sola richiesta attiva per utente e libro. Attivalo solo quando serve affidare contemporaneamente allo stesso utente più copie fisiche dello stesso titolo, per esempio in una scuola o in un centro didattico. Ogni prestito deve riferirsi a una copia distinta e conta separatamente nel limite `max_active_loans_per_user`; richieste in attesa e prenotazioni restano invece uniche per utente e libro. + --- ### 8. **Impostazioni Avanzate** - Personalizzazioni e funzionalità extra @@ -487,6 +490,7 @@ Categorie effettivamente usate dal `SettingsController` (chiave `category`): ('loans', 'pickup_expiry_days', '3'), ('loans', 'max_renewals', '3'), ('loans', 'max_active_loans_per_user', '0'), -- 0 = illimitato +('loans', 'allow_multiple_loans_same_book', '0'), -- 0 = comportamento standard, 1 = copie distinte ('advanced', 'days_before_expiry_warning', '3'), ('advanced', 'session_lifetime', '180'), -- minuti ('advanced', 'force_https', '0'), diff --git a/installer/database/data_da_DK.sql b/installer/database/data_da_DK.sql index 0811cfc38..cc07f503d 100644 --- a/installer/database/data_da_DK.sql +++ b/installer/database/data_da_DK.sql @@ -23,6 +23,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('loans', 'max_active_loans_per_user', '0', 'Maksimalt antal aktive lån pr. bruger (0 = ingen grænse)') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('loans', 'allow_multiple_loans_same_book', '0', 'Tillad den samme bruger at have flere aktive lån af samme bog på forskellige fysiske eksemplarer') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_renewals', '3', 'Maksimalt antal fornyelser tilladt pr. lån') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_de_DE.sql b/installer/database/data_de_DE.sql index f2f35735f..a253a52a5 100644 --- a/installer/database/data_de_DE.sql +++ b/installer/database/data_de_DE.sql @@ -206,6 +206,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('loans', 'max_active_loans_per_user', '0', 'Maximale Anzahl aktiver Ausleihen pro Benutzer (0 = kein Limit)') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('loans', 'allow_multiple_loans_same_book', '0', 'Erlaubt demselben Benutzer mehrere aktive Ausleihen desselben Buches mit unterschiedlichen physischen Exemplaren') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_renewals', '3', 'Maximale Anzahl erlaubter Verlängerungen pro Ausleihe') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index c6e357e30..3dc15ec2c 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -206,6 +206,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('loans', 'max_active_loans_per_user', '0', 'Maximum active loans per user (0 = no limit)') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('loans', 'allow_multiple_loans_same_book', '0', 'Allow the same user to have multiple active loans for the same book on distinct physical copies') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_renewals', '3', 'Maximum number of renewals allowed per loan') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_fr_FR.sql b/installer/database/data_fr_FR.sql index 5b7d6f2e2..64cb261ef 100644 --- a/installer/database/data_fr_FR.sql +++ b/installer/database/data_fr_FR.sql @@ -206,6 +206,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('loans', 'max_active_loans_per_user', '0', 'Nombre maximal d\'emprunts actifs par utilisateur (0 = aucune limite)') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('loans', 'allow_multiple_loans_same_book', '0', 'Autorise un même utilisateur à avoir plusieurs emprunts actifs du même livre sur des exemplaires physiques distincts') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_renewals', '3', 'Nombre maximal de renouvellements autorisés par emprunt') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index 2286858be..a9a51c6af 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -23,6 +23,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('loans', 'max_active_loans_per_user', '0', 'Numero massimo di prestiti attivi per utente (0 = nessun limite)') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('loans', 'allow_multiple_loans_same_book', '0', 'Consente allo stesso utente di avere più prestiti attivi dello stesso libro su copie fisiche distinte') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_renewals', '3', 'Numero massimo di rinnovi consentiti per prestito') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/migrations/migrate_0.7.64-rc.1.sql b/installer/database/migrations/migrate_0.7.64-rc.1.sql new file mode 100644 index 000000000..f05eff08e --- /dev/null +++ b/installer/database/migrations/migrate_0.7.64-rc.1.sql @@ -0,0 +1,97 @@ +-- Migration 0.7.64-rc.1 +-- Durable claim/retry state for the ready-for-pickup notification. +-- +-- The marker is written BEFORE any ALTER TABLE (which implicitly commits in +-- MySQL). Existing rows receive 1 through the ADD COLUMN default itself; the +-- default is then changed to 0 for future rows. Deliberately no UPDATE touches +-- prestiti here: older circulation triggers validate every UPDATE and can +-- reject unrelated backfill writes on damaged/overdue legacy rows. + +SET @pickup_sent_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'prestiti' + AND COLUMN_NAME = 'pickup_notification_sent' +); +SET @pickup_token_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'prestiti' + AND COLUMN_NAME = 'pickup_notification_claim_token' +); +SET @pickup_attempt_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'prestiti' + AND COLUMN_NAME = 'pickup_notification_last_attempt_at' +); + +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT + 'migrations', + 'pickup_notification_backfill_0_7_64', + IF(@pickup_sent_exists = 0, 'pending', 'done'), + 'Resumable state for the 0.7.64 pickup notification schema' +WHERE NOT EXISTS ( + SELECT 1 + FROM system_settings + WHERE category = 'migrations' + AND setting_key = 'pickup_notification_backfill_0_7_64' +); + +SET @sql = IF( + @pickup_sent_exists = 0, + 'ALTER TABLE `prestiti` ADD COLUMN `pickup_notification_sent` TINYINT(1) DEFAULT 1 COMMENT ''claim/retry flag for the ready-for-pickup email''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + @pickup_token_exists = 0, + 'ALTER TABLE `prestiti` ADD COLUMN `pickup_notification_claim_token` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL DEFAULT NULL COMMENT ''owner token for an in-flight pickup email claim''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + @pickup_attempt_exists = 0, + 'ALTER TABLE `prestiti` ADD COLUMN `pickup_notification_last_attempt_at` DATETIME NULL DEFAULT NULL COMMENT ''fair retry ordering for pickup email attempts''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @pickup_backfill_marker = ( + SELECT setting_value + FROM system_settings + WHERE category = 'migrations' + AND setting_key = 'pickup_notification_backfill_0_7_64' + LIMIT 1 +); +SET @sql = IF( + @pickup_backfill_marker = 'pending' + OR @pickup_backfill_marker REGEXP '^pending:[0-9]+$', + 'ALTER TABLE `prestiti` MODIFY COLUMN `pickup_notification_sent` TINYINT(1) DEFAULT 0 COMMENT ''claim/retry flag for the ready-for-pickup email''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE system_settings + SET setting_value = 'done' + WHERE category = 'migrations' + AND setting_key = 'pickup_notification_backfill_0_7_64' + AND setting_value = @pickup_backfill_marker + AND ( + @pickup_backfill_marker = 'pending' + OR @pickup_backfill_marker REGEXP '^pending:[0-9]+$' + ); diff --git a/installer/database/schema.sql b/installer/database/schema.sql index 5fbde893b..f707e1774 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -850,6 +850,9 @@ CREATE TABLE `prestiti` ( `attivo` tinyint(1) NOT NULL DEFAULT '1', `warning_sent` tinyint(1) DEFAULT '0', `overdue_notification_sent` tinyint(1) DEFAULT '0', + `pickup_notification_sent` tinyint(1) DEFAULT '0', + `pickup_notification_claim_token` char(32) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, + `pickup_notification_last_attempt_at` datetime DEFAULT NULL, `recall_count` int NOT NULL DEFAULT '0', `last_recall_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), diff --git a/installer/database/triggers.sql b/installer/database/triggers.sql index e048b7676..218f79326 100755 --- a/installer/database/triggers.sql +++ b/installer/database/triggers.sql @@ -10,6 +10,12 @@ CREATE TRIGGER `trg_check_active_prestito_before_insert` BEFORE INSERT ON `prestiti` FOR EACH ROW BEGIN + -- The web/CLI application initializes this connection-scoped value from + -- DateHelper (app.timezone). Direct SQL clients leave it NULL and retain + -- the safe database-local fallback. + DECLARE application_today DATE; + SET application_today = COALESCE(@pinakes_application_date, CURRENT_DATE()); + -- I7: la copia di un prestito deve appartenere al libro del prestito stesso -- (vale anche per righe chiuse: previene il decoupling libro_id/copia_id, #fix/loan-state-bugs BUG2). IF NEW.copia_id IS NOT NULL @@ -38,8 +44,17 @@ BEGIN SELECT 1 FROM prestiti p WHERE p.copia_id = NEW.copia_id - AND p.data_prestito <= NEW.data_scadenza - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= NEW.data_prestito) + -- Effective interval of NEW: an overdue physical loan remains + -- open-ended until the copy is actually returned, regardless of + -- whether the future row or the overdue row was inserted first. + AND (NEW.stato = 'in_ritardo' + OR (NEW.stato = 'in_corso' AND NEW.data_scadenza < application_today) + OR p.data_prestito <= NEW.data_scadenza) + -- application_today is authoritative for app writers; its + -- declaration keeps CURRENT_DATE() as the direct-SQL fallback. + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < application_today) + OR p.data_scadenza >= NEW.data_prestito) AND ( (p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo','prenotato','da_ritirare')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) @@ -60,6 +75,11 @@ CREATE TRIGGER `trg_check_active_prestito_before_update` BEFORE UPDATE ON `prestiti` FOR EACH ROW BEGIN + -- See the INSERT trigger: app writers bind DateHelper::today() through a + -- connection variable; direct SQL writes fall back to the database date. + DECLARE application_today DATE; + SET application_today = COALESCE(@pinakes_application_date, CURRENT_DATE()); + -- I7: la copia di un prestito deve appartenere al libro del prestito stesso -- (vale anche per righe chiuse: previene il decoupling libro_id/copia_id, #fix/loan-state-bugs BUG2). IF NEW.copia_id IS NOT NULL @@ -118,14 +138,30 @@ BEGIN OR NOT (OLD.attivo = 1 OR OLD.stato = 'pendente') OR NOT (OLD.data_prestito <=> NEW.data_prestito) OR NOT (OLD.data_scadenza <=> NEW.data_scadenza) + -- A state-only transition can turn a finite commitment into an + -- open-ended physical hold (for example prenotato -> stale + -- in_corso). Re-run the gate whenever that semantic bit changes. + OR NOT ( + (OLD.stato = 'in_ritardo' + OR (OLD.stato = 'in_corso' AND OLD.data_scadenza < application_today)) + <=> + (NEW.stato = 'in_ritardo' + OR (NEW.stato = 'in_corso' AND NEW.data_scadenza < application_today)) + ) )) THEN IF EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = NEW.copia_id AND p.id <> NEW.id - AND p.data_prestito <= NEW.data_scadenza - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= NEW.data_prestito) + AND (NEW.stato = 'in_ritardo' + OR (NEW.stato = 'in_corso' AND NEW.data_scadenza < application_today) + OR p.data_prestito <= NEW.data_scadenza) + -- See the INSERT trigger: application_today is authoritative for + -- app writes and database-local only for uninitialized SQL clients. + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < application_today) + OR p.data_scadenza >= NEW.data_prestito) AND ( (p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo','prenotato','da_ritirare')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL) @@ -133,8 +169,12 @@ BEGIN AND NOT ( OLD.copia_id <=> NEW.copia_id AND (OLD.attivo = 1 OR OLD.stato = 'pendente') - AND p.data_prestito <= OLD.data_scadenza - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= OLD.data_prestito) + AND (OLD.stato = 'in_ritardo' + OR (OLD.stato = 'in_corso' AND OLD.data_scadenza < application_today) + OR p.data_prestito <= OLD.data_scadenza) + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < application_today) + OR p.data_scadenza >= OLD.data_prestito) ) ) THEN SIGNAL SQLSTATE '45000' diff --git a/locale/da_DK.json b/locale/da_DK.json index 0cf15f9ef..97545a1a0 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -1426,6 +1426,7 @@ "Errore di sicurezza. Aggiorna la pagina e riprova": "Sikkerhedsfejl. Genindlæs siden, og prøv igen", "Errore di sicurezza. Ricarica la pagina e riprova": "Sikkerhedsfejl. Genindlæs siden, og prøv igen", "Errore di sicurezza. Ricarica la pagina e riprova.": "Sikkerhedsfejl. Genindlæs siden, og prøv igen.", + "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.": "Anmodningen er allerede behandlet eller er ikke længere gyldig. Kontrollér udlånslisten, før du prøver igen.", "Errore di sicurezza. Riprova.": "Sikkerhedsfejl. Prøv igen.", "Errore di sistema.": "Systemfejl.", "Errore durante il caricamento": "Fejl under indlæsning", @@ -6875,5 +6876,19 @@ "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).": "%s valgte lån modtager rykkeren (kun de forfaldne).", "Impossibile generare la ricevuta PDF.": "Kvitterings-PDF'en kunne ikke genereres.", "Attivazione prestito rinviata: nessuna copia libera": "Aktivering af lån udskudt: intet eksemplar ledigt", - "Attivazione prestito rinviata: copia assegnata non in sede": "Aktivering af lån udskudt: tildelt eksemplar ikke på biblioteket" + "Attivazione prestito rinviata: copia assegnata non in sede": "Aktivering af lån udskudt: tildelt eksemplar ikke på biblioteket", + "Più copie dello stesso titolo": "Flere eksemplarer af samme titel", + "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Tillad den samme bruger at have flere aktive lån af samme bog, så længe hvert lån er knyttet til et særskilt fysisk eksemplar.", + "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Tillad kun flere lån af samme titel på forskellige fysiske eksemplarer", + "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Hvert lån skal være knyttet til et særskilt fysisk eksemplar; afventende anmodninger og reservationer er fortsat begrænset til én pr. bruger og bog.", + "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Afventende anmodninger og reservationer er fortsat unikke pr. bruger og bog. Hvert eksemplar tæller med i grænsen for aktive lån.", + "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Tilstanden for flere lån er aktiv: Bogen forbliver valgt; scan det næste eksemplar for at fortsætte.", + "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo": "Det ønskede tidsrum er allerede passeret: Opdater lånets datoer, før du godkender det", + "Ritiro annullato il": "Afhentning annulleret den", + "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.": "En anden handling var ved at opdatere den samme bog: Intet blev gemt, prøv igen nu.", + "Stato prenotazione non valido: modifica non salvata.": "Ugyldig reservationsstatus: Ændringer ikke gemt.", + "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.": "Denne reservation er allerede blevet konverteret til et stadig åbent lån: Håndter lånet fra siden Udlån i stedet for at redigere reservationen.", + "MaintenanceService aggiornamento marker cooldown fallito": "MaintenanceService: Opdatering af cooldown-markør mislykkedes", + "MaintenanceService errore recupero notifiche ritiro": "MaintenanceService: Fejl ved hentning af afhentningsnotifikationer", + "Errore: la nuova data di scadenza non può essere nel passato.": "Fejl: Den nye afleveringsdato må ikke være i fortiden." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 716676e5e..b5900f70c 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -1426,6 +1426,7 @@ "Errore di sicurezza. Aggiorna la pagina e riprova": "Sicherheitsfehler. Aktualisieren Sie die Seite und versuchen Sie es erneut", "Errore di sicurezza. Ricarica la pagina e riprova": "Sicherheitsfehler. Laden Sie die Seite neu und versuchen Sie es erneut", "Errore di sicurezza. Ricarica la pagina e riprova.": "Sicherheitsfehler. Laden Sie die Seite neu und versuchen Sie es erneut.", + "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.": "Die Anfrage wurde bereits verarbeitet oder ist nicht mehr gültig. Prüfen Sie die Ausleihliste, bevor Sie es erneut versuchen.", "Errore di sicurezza. Riprova.": "Sicherheitsfehler. Bitte versuchen Sie es erneut.", "Errore di sistema.": "Systemfehler.", "Errore durante il caricamento": "Fehler beim Laden", @@ -6875,5 +6876,19 @@ "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).": "%s ausgewählte Ausleihen erhalten die Mahnung (nur die überfälligen).", "Impossibile generare la ricevuta PDF.": "Die Quittungs-PDF konnte nicht erstellt werden.", "Attivazione prestito rinviata: nessuna copia libera": "Ausleihe-Aktivierung verschoben: kein Exemplar frei", - "Attivazione prestito rinviata: copia assegnata non in sede": "Ausleihe-Aktivierung verschoben: zugewiesenes Exemplar nicht in der Bibliothek" + "Attivazione prestito rinviata: copia assegnata non in sede": "Ausleihe-Aktivierung verschoben: zugewiesenes Exemplar nicht in der Bibliothek", + "Più copie dello stesso titolo": "Mehrere Exemplare desselben Titels", + "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Erlaube demselben Benutzer mehrere aktive Ausleihen desselben Buches, sofern jede Ausleihe einem anderen physischen Exemplar zugeordnet ist.", + "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Mehrere Ausleihen desselben Titels nur für unterschiedliche physische Exemplare zulassen", + "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Jede Ausleihe muss einem anderen physischen Exemplar zugeordnet sein; ausstehende Anfragen und Vormerkungen bleiben auf jeweils eine pro Benutzer und Buch beschränkt.", + "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Ausstehende Anfragen und Vormerkungen bleiben pro Benutzer und Buch eindeutig. Jedes Exemplar zählt zum Limit für aktive Ausleihen.", + "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Mehrfachausleihmodus aktiv: Das Buch bleibt ausgewählt; scannen Sie das nächste Exemplar, um fortzufahren.", + "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo": "Das angeforderte Zeitfenster ist bereits verstrichen: Aktualisiere die Ausleihdaten, bevor du die Ausleihe genehmigst", + "Ritiro annullato il": "Abholung storniert am", + "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.": "Ein anderer Vorgang hat gerade dasselbe Buch aktualisiert: Es wurde nichts gespeichert, bitte versuche es jetzt erneut.", + "Stato prenotazione non valido: modifica non salvata.": "Ungültiger Vormerkungsstatus: Änderungen nicht gespeichert.", + "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.": "Diese Vormerkung wurde bereits in eine noch offene Ausleihe umgewandelt: Verwalte die Ausleihe auf der Seite Ausleihen, statt die Vormerkung zu bearbeiten.", + "MaintenanceService aggiornamento marker cooldown fallito": "MaintenanceService: Aktualisierung des Cooldown-Markers fehlgeschlagen", + "MaintenanceService errore recupero notifiche ritiro": "MaintenanceService: Fehler beim Abrufen der Abholbenachrichtigungen", + "Errore: la nuova data di scadenza non può essere nel passato.": "Fehler: Das neue Fälligkeitsdatum darf nicht in der Vergangenheit liegen." } diff --git a/locale/en_US.json b/locale/en_US.json index d0d215ae9..6621e9c11 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -1426,6 +1426,7 @@ "Errore di sicurezza. Aggiorna la pagina e riprova": "Security error. Refresh the page and try again", "Errore di sicurezza. Ricarica la pagina e riprova": "Security error. Reload the page and try again", "Errore di sicurezza. Ricarica la pagina e riprova.": "Security error. Reload the page and try again.", + "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.": "The request has already been processed or is no longer valid. Check the loan list before trying again.", "Errore di sicurezza. Riprova.": "Security error. Please try again.", "Errore di sistema.": "System error.", "Errore durante il caricamento": "Error during loading", @@ -6875,5 +6876,19 @@ "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).": "%s selected loans will receive the reminder (only the overdue ones).", "Impossibile generare la ricevuta PDF.": "Could not generate the receipt PDF.", "Attivazione prestito rinviata: nessuna copia libera": "Loan activation deferred: no copy free", - "Attivazione prestito rinviata: copia assegnata non in sede": "Loan activation deferred: assigned copy not in library" + "Attivazione prestito rinviata: copia assegnata non in sede": "Loan activation deferred: assigned copy not in library", + "Più copie dello stesso titolo": "Multiple copies of the same title", + "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Allow the same user to have multiple active loans for the same book, provided each loan is associated with a distinct physical copy.", + "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Allow multiple loans of the same title only for distinct physical copies", + "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Each loan must be associated with a distinct physical copy; pending requests and reservations remain limited to one per user and book.", + "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Pending requests and reservations remain unique per user and book. Each copy counts toward the active loan limit.", + "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Multiple-loan mode is active: the book remains selected; scan the next copy to continue.", + "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo": "The requested window has already passed: update the loan dates before approving it", + "Ritiro annullato il": "Pickup cancelled on", + "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.": "Another operation was updating the same book: nothing was saved, try again now.", + "Stato prenotazione non valido: modifica non salvata.": "Invalid reservation status: changes not saved.", + "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.": "This reservation has already been converted into a still-open loan: manage the loan from the Loans page instead of editing the reservation.", + "MaintenanceService aggiornamento marker cooldown fallito": "MaintenanceService cooldown marker update failed", + "MaintenanceService errore recupero notifiche ritiro": "MaintenanceService pickup notification recovery error", + "Errore: la nuova data di scadenza non può essere nel passato.": "Error: the new due date cannot be in the past." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 595bc47c4..47c0302d9 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -1426,6 +1426,7 @@ "Errore di sicurezza. Aggiorna la pagina e riprova": "Erreur de sécurité. Actualisez la page et réessayez", "Errore di sicurezza. Ricarica la pagina e riprova": "Erreur de sécurité. Rechargez la page et réessayez", "Errore di sicurezza. Ricarica la pagina e riprova.": "Erreur de sécurité. Rechargez la page et réessayez.", + "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.": "La demande a déjà été traitée ou n'est plus valide. Consultez la liste des prêts avant de réessayer.", "Errore di sicurezza. Riprova.": "Erreur de sécurité. Réessayez.", "Errore di sistema.": "Erreur système.", "Errore durante il caricamento": "Erreur lors du chargement", @@ -6875,5 +6876,19 @@ "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).": "%s prêts sélectionnés recevront le rappel (uniquement ceux en retard).", "Impossibile generare la ricevuta PDF.": "Impossible de générer le PDF du reçu.", "Attivazione prestito rinviata: nessuna copia libera": "Activation du prêt reportée : aucun exemplaire libre", - "Attivazione prestito rinviata: copia assegnata non in sede": "Activation du prêt reportée : exemplaire attribué absent de la bibliothèque" + "Attivazione prestito rinviata: copia assegnata non in sede": "Activation du prêt reportée : exemplaire attribué absent de la bibliothèque", + "Più copie dello stesso titolo": "Plusieurs exemplaires du même titre", + "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Autorisez un même utilisateur à avoir plusieurs emprunts actifs du même livre, à condition que chaque emprunt soit associé à un exemplaire physique distinct.", + "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Autoriser plusieurs emprunts du même titre uniquement sur des exemplaires physiques distincts", + "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Chaque emprunt doit être associé à un exemplaire physique distinct ; les demandes en attente et les réservations restent limitées à une par utilisateur et par livre.", + "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Les demandes en attente et les réservations restent uniques par utilisateur et par livre. Chaque exemplaire compte dans la limite des emprunts actifs.", + "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Mode de prêts multiples actif : le livre reste sélectionné ; scannez l'exemplaire suivant pour continuer.", + "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo": "La période demandée est déjà passée : mettez à jour les dates du prêt avant de l'approuver", + "Ritiro annullato il": "Retrait annulé le", + "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.": "Une autre opération mettait à jour le même livre : aucune modification enregistrée, réessayez maintenant.", + "Stato prenotazione non valido: modifica non salvata.": "Statut de réservation non valide : modifications non enregistrées.", + "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.": "Cette réservation a déjà été convertie en un prêt encore ouvert : gérez le prêt depuis la page Prêts au lieu de modifier la réservation.", + "MaintenanceService aggiornamento marker cooldown fallito": "MaintenanceService : échec de la mise à jour du marqueur de cooldown", + "MaintenanceService errore recupero notifiche ritiro": "MaintenanceService : erreur de récupération des notifications de retrait", + "Errore: la nuova data di scadenza non può essere nel passato.": "Erreur : la nouvelle date d'échéance ne peut pas être dans le passé." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 0444f8a9d..36d8c1eea 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -1426,6 +1426,7 @@ "Errore di sicurezza. Aggiorna la pagina e riprova": "Errore di sicurezza. Aggiorna la pagina e riprova", "Errore di sicurezza. Ricarica la pagina e riprova": "Errore di sicurezza. Ricarica la pagina e riprova", "Errore di sicurezza. Ricarica la pagina e riprova.": "Errore di sicurezza. Ricarica la pagina e riprova.", + "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.": "La richiesta è già stata elaborata o non è più valida. Verifica l'elenco dei prestiti prima di riprovare.", "Errore di sicurezza. Riprova.": "Errore di sicurezza. Riprova.", "Errore di sistema.": "Errore di sistema.", "Errore durante il caricamento": "Errore durante il caricamento", @@ -6875,5 +6876,19 @@ "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).": "%s prestiti selezionati riceveranno il sollecito (solo quelli scaduti).", "Impossibile generare la ricevuta PDF.": "Impossibile generare la ricevuta PDF.", "Attivazione prestito rinviata: nessuna copia libera": "Attivazione prestito rinviata: nessuna copia libera", - "Attivazione prestito rinviata: copia assegnata non in sede": "Attivazione prestito rinviata: copia assegnata non in sede" + "Attivazione prestito rinviata: copia assegnata non in sede": "Attivazione prestito rinviata: copia assegnata non in sede", + "Più copie dello stesso titolo": "Più copie dello stesso titolo", + "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.", + "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte", + "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.", + "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.", + "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.", + "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo": "La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo", + "Ritiro annullato il": "Ritiro annullato il", + "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.": "Un'altra operazione stava aggiornando lo stesso libro: nessuna modifica salvata, riprova ora.", + "Stato prenotazione non valido: modifica non salvata.": "Stato prenotazione non valido: modifica non salvata.", + "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.": "Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.", + "MaintenanceService aggiornamento marker cooldown fallito": "MaintenanceService aggiornamento marker cooldown fallito", + "MaintenanceService errore recupero notifiche ritiro": "MaintenanceService errore recupero notifiche ritiro", + "Errore: la nuova data di scadenza non può essere nel passato.": "Errore: la nuova data di scadenza non può essere nel passato." } diff --git a/scripts/_db_bootstrap.php b/scripts/_db_bootstrap.php index 2f54ffb81..b56db248f 100644 --- a/scripts/_db_bootstrap.php +++ b/scripts/_db_bootstrap.php @@ -8,8 +8,9 @@ * future ones) bypass the Slim/config/settings.php bootstrap and need a * minimal mysqli wired from .env. The logic for that — read DB_*, normalise * host when DB_SOCKET is set so mysqli actually honours the socket, then - * connect — was duplicated verbatim across scripts. CodeRabbit round 8 - * flagged the drift risk; this helper centralises it. + * connect, select the canonical charset, and publish the application day to + * circulation triggers — was duplicated across scripts. This helper + * centralises the complete first-party CLI session contract. * * Caller is responsible for loading .env into $_ENV (typically via * `Dotenv::createImmutable($projectRoot)->load()`) BEFORE invoking this. @@ -54,6 +55,10 @@ function pinakes_db_from_env(): mysqli $host = 'localhost'; } - return new mysqli($host, $user, $pass, $name, $port, $sock ?: null); + $db = new mysqli($host, $user, $pass, $name, $port, $sock ?: null); + $db->set_charset('utf8mb4'); + \App\Support\DateHelper::synchronizeDatabaseSession($db); + + return $db; } } diff --git a/scripts/ci-check-zap-report.sh b/scripts/ci-check-zap-report.sh new file mode 100755 index 000000000..75d8c9e1d --- /dev/null +++ b/scripts/ci-check-zap-report.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "Usage: $0 REPORT_JSON CATALOGUE_IDENTIFIERS_JSON PUBLIC_CATALOGUE_URLS_JSON PUBLIC_EXAMPLE_IDENTIFIERS_JSON" >&2 + exit 2 +fi + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPORT_FILE=$1 +IDENTIFIERS_FILE=$2 +PUBLIC_URLS_FILE=$3 +PUBLIC_EXAMPLES_FILE=$4 +BLOCKING_FILE=$(mktemp "${TMPDIR:-/tmp}/pinakes-zap-blocking.XXXXXX") +trap 'rm -f "$BLOCKING_FILE"' EXIT + +test -s "$REPORT_FILE" || { echo "ZAP JSON report is missing" >&2; exit 1; } +test -s "$IDENTIFIERS_FILE" || { echo "Catalogue identifier context is missing" >&2; exit 1; } +test -s "$PUBLIC_URLS_FILE" || { echo "Public URL context is missing" >&2; exit 1; } +test -s "$PUBLIC_EXAMPLES_FILE" || { echo "Public example identifier context is missing" >&2; exit 1; } + +# Slurp deliberately: the jq policy rejects zero or multiple JSON documents. +# Materialize its single validated array before counting so an upstream jq +# error cannot be swallowed by a pipeline or a non-numeric shell comparison. +jq -s \ + --slurpfile catalogue_identifiers "$IDENTIFIERS_FILE" \ + --slurpfile public_catalogue_urls "$PUBLIC_URLS_FILE" \ + --slurpfile public_example_identifiers "$PUBLIC_EXAMPLES_FILE" \ + -f "$ROOT_DIR/scripts/ci-zap-blocking-alerts.jq" \ + "$REPORT_FILE" > "$BLOCKING_FILE" +jq -e 'type == "array"' "$BLOCKING_FILE" >/dev/null + +blocking=$(jq -r 'length' "$BLOCKING_FILE") +if [[ "$blocking" -gt 0 ]]; then + jq -r '.[] | "[" + (.riskdesc // "") + "] " + (.alert // "") + ": " + (.desc // "")' "$BLOCKING_FILE" + exit 1 +fi + +echo "ZAP found no blocking medium/high passive-scan alerts (allowlisted: known catalogue identifiers on bibliographic pages and shipped public ISBN examples on the target origin)" diff --git a/scripts/ci-playwright-policy.js b/scripts/ci-playwright-policy.js index db83a969f..7830fe6c6 100755 --- a/scripts/ci-playwright-policy.js +++ b/scripts/ci-playwright-policy.js @@ -42,6 +42,81 @@ function fail(message) { process.exitCode = 1; } +/** + * Detect workflows that can create, replace, edit, or delete GitHub Release + * state. This is deliberately broader than `gh release`: a second publisher + * can use the REST API, github-script, curl, or one of several release actions. + * Read-only `gh api .../releases` calls remain allowed for upgrade tests. + */ +function mutatesGitHubReleases(source) { + const normalized = source + .replace(/\\\r?\n[\t ]*/g, ' ') + .replace(/^[\t ]*#.*$/gm, ' '); + const releaseEndpoint = /(?:api\.github\.com\/)?repos\/(?:(?:\$\{?GITHUB_REPOSITORY\}?|\$\{\{\s*github\.repository\s*\}\})|[^/\s'"`]+\/[^/\s'"`]+)\/releases(?:\/|\?|\s|['"`]|$)/i; + + if (/\bgh\s+release\s+(?:create|upload|edit|delete)\b/i.test(normalized)) return true; + if (/uses:\s*(?:softprops\/action-gh-release|actions\/(?:create-release|upload-release-asset)|ncipollo\/release-action|marvinpinto\/action-automatic-releases|svenstaro\/upload-release-action)@/i.test(normalized)) { + return true; + } + if (/\b(?:github|octokit)(?:\.rest)?\.repos\.(?:createRelease|updateRelease|deleteRelease|uploadReleaseAsset)\s*\(/i.test(normalized)) { + return true; + } + if (/\b(?:github|octokit)\.request\s*\(\s*['"`](?:POST|PATCH|PUT|DELETE)\s+\/repos\/[^/]+\/[^/]+\/releases\b/i.test(normalized)) { + return true; + } + // Parse only the first argument of each request call. A fixed character + // window can accidentally combine method/url fields from a later object and + // either reject a read-only workflow or conceal which call is authoritative. + const requestCall = /\b(?:github|octokit)\.request\s*\(/ig; + let requestMatch; + while ((requestMatch = requestCall.exec(normalized)) !== null) { + const openParen = normalized.indexOf('(', requestMatch.index); + const [options = ''] = callArguments(normalized, openParen); + if (!/^\s*\{/.test(options)) continue; + const trivia = String.raw`(?:\s|\/\/[^\r\n]*(?:\r?\n|$)|\/\*[\s\S]*?\*\/)*`; + const propertyStart = String.raw`(?:^|[,\{])${trivia}`; + const method = new RegExp( + `${propertyStart}(?:method|['"]method['"])${trivia}:${trivia}['"\`](POST|PATCH|PUT|DELETE)['"\`]`, + 'i', + ).exec(options); + const url = new RegExp( + `${propertyStart}(?:url|['"]url['"])${trivia}:${trivia}(['"\`])([\\s\\S]*?)\\1`, + 'i', + ).exec(options); + if (method !== null && url !== null && releaseEndpoint.test(url[2])) return true; + } + if (/\bmutation\b[\s\S]*\b(?:createRelease|updateRelease|deleteRelease)\s*\(/i.test(normalized)) { + return true; + } + + for (const command of normalized.split(/[;\n]/)) { + const mutatingMethod = /(?:^|\s)(?:-X\s*|--method(?:=|\s+))(?:POST|PATCH|PUT|DELETE)\b/i; + // `gh api` defaults to POST as soon as a typed/raw field or --input is + // supplied, so looking only for an explicit -X/--method leaves an easy + // second-publisher escape hatch. + const ghApiPayload = /(?:^|\s)(?:-f|--raw-field|-F|--field|--input)(?:=|\s+)/i; + if (/\bgh\s+api\b/i.test(command) + && releaseEndpoint.test(command) + && (mutatingMethod.test(command) || ghApiPayload.test(command))) { + return true; + } + const curlPayload = /(?:^|\s)(?:-d|--data(?:-ascii|-binary|-raw|-urlencode)?|-F|--form(?:-string)?|--json|--upload-file|-T)(?:=|\s)/i; + if (/\bcurl\b/i.test(command) + && /(?:api\.github\.com|\$\{?GITHUB_API_URL\}?)/i.test(command) + && releaseEndpoint.test(command) + && (mutatingMethod.test(command) || curlPayload.test(command))) { + return true; + } + if (/\bInvoke-RestMethod\b/i.test(command) + && releaseEndpoint.test(command) + && /(?:^|\s)-Method\s+(?:Post|Patch|Put|Delete)\b/i.test(command)) { + return true; + } + } + + return false; +} + /** * Return the top-level arguments of a JavaScript call. This deliberately small * lexer is enough for test sources and avoids adding a parser dependency just @@ -196,12 +271,25 @@ function checkPolicy() { } const releaseWorkflowPath = path.join(root, '.github', 'workflows', 'release.yml'); + const releaseOrchestratorPath = path.join(root, 'scripts', 'create-release.sh'); const releasePolicyPath = path.join(root, 'scripts', 'ci-verify-release-source.sh'); const releasePolicyTestPath = path.join(root, 'tests', 'release-source-policy.test.sh'); - if (!fs.existsSync(releasePolicyPath) || !fs.existsSync(releasePolicyTestPath)) { - fail('release source policy and its regression test are required'); + if ( + !fs.existsSync(releaseOrchestratorPath) + || !fs.existsSync(releasePolicyPath) + || !fs.existsSync(releasePolicyTestPath) + ) { + // fail() only sets process.exitCode: return as well, or the + // fs.readFileSync(releaseOrchestratorPath) below would throw a raw + // ENOENT stack trace instead of this policy message. + fail('release orchestrator, source policy, and regression test are required'); + return; } - if (fs.existsSync(releaseWorkflowPath)) { + if (!fs.existsSync(releaseWorkflowPath)) { + // Every contract below guards the sole-publisher pipeline; a deleted or + // renamed release.yml must fail the policy, not skip it. + fail('release workflow (.github/workflows/release.yml) is required'); + } else { const releaseWorkflow = fs.readFileSync(releaseWorkflowPath, 'utf8'); if (!releaseWorkflow.includes('bash scripts/ci-verify-release-source.sh')) { fail('release workflow does not enforce the stable/prerelease source policy'); @@ -211,6 +299,80 @@ function checkPolicy() { fail(`release workflow is missing permission: ${permission}`); } } + for (const contract of [ + 'bash bin/build-release.sh --skip-build', + 'name: Verify and build release (read-only)', + 'name: Attest and publish verified release', + 'needs: build', + 'actions/download-artifact@', + 'gh release create', + '--draft', + '.uploader.login == "github-actions[bot]"', + 'gh release edit', + '.immutable == true', + ]) { + if (!releaseWorkflow.includes(contract)) { + fail(`release workflow is missing sole-publisher contract: ${contract}`); + } + } + const buildJobStart = releaseWorkflow.indexOf('\n build:'); + const publishJobStart = releaseWorkflow.indexOf('\n publish:'); + const buildJob = buildJobStart >= 0 && publishJobStart > buildJobStart + ? releaseWorkflow.slice(buildJobStart, publishJobStart) + : ''; + const publishJob = publishJobStart >= 0 ? releaseWorkflow.slice(publishJobStart) : ''; + if (!buildJob.includes('contents: read') + || /contents:\s*write|id-token:\s*write|attestations:\s*write/.test(buildJob)) { + fail('release build job must remain read-only and must not receive OIDC/attestation write authority'); + } + for (const permission of ['contents: write', 'id-token: write', 'attestations: write']) { + if (!publishJob.includes(permission)) { + fail(`release publish job is missing permission: ${permission}`); + } + } + } + + const releaseOrchestrator = fs.readFileSync(releaseOrchestratorPath, 'utf8'); + for (const contract of [ + 'bash scripts/ci-verify-release-source.sh', + 'git tag -a', + 'git push origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}"', + 'gh run watch', + '.uploader.login == "github-actions[bot]"', + 'immutable-releases', + 'workflow_run_floor', + '.databaseId > $floor', + "'.immutable'", + ]) { + if (!releaseOrchestrator.includes(contract)) { + fail(`release orchestrator is missing contract: ${contract}`); + } + } + if (/git\s+archive|bin\/build-release\.sh|gh\s+release\s+(?:create|upload|edit)/.test(releaseOrchestrator)) { + fail('release orchestrator must not build or publish a competing artifact'); + } + + const upgradeSmokePath = path.join(root, '.github', 'workflows', 'ci-upgrade-smoke.yml'); + if (fs.existsSync(upgradeSmokePath)) { + const upgradeSmoke = fs.readFileSync(upgradeSmokePath, 'utf8'); + for (const contract of [ + '$updater->runMigrations($from, $target)', + 'BASELINE_APP', + 'list-source-expectations.php plugins', + 'migrate_0.7.64-rc.1.sql', + 'pickup_notification_sent', + 'Second Updater pass was not an idempotent no-op', + ]) { + if (!upgradeSmoke.includes(contract)) { + fail(`upgrade smoke is missing production-migration contract: ${contract}`); + } + } + if (/runMigrations\(\$target,\s*\$target\)/.test(upgradeSmoke)) { + fail('upgrade smoke bypasses migration selection by using target as the baseline'); + } + if (upgradeSmoke.includes('- name: Apply current branch migrations')) { + fail('upgrade smoke must not pre-apply current SQL outside Updater'); + } } const qualityWorkflowPath = path.join(root, '.github', 'workflows', 'ci-quality.yml'); @@ -222,10 +384,48 @@ function checkPolicy() { } const workflowDir = path.join(root, '.github', 'workflows'); + const releaseMutationFixtures = [ + 'gh api -X POST repos/example/project/releases', + 'gh api repos/example/project/releases/1 --method DELETE', + 'gh api repos/example/project/releases -f tag_name=v1.2.3', + 'gh api repos/example/project/releases/1/assets --input payload.json', + 'gh api -X POST "repos/${GITHUB_REPOSITORY}/releases"', + 'gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/123"', + 'gh api --method PATCH "repos/${{ github.repository }}/releases/123"', + 'curl -X PATCH https://api.github.com/repos/example/project/releases/1', + 'curl --json @payload.json https://api.github.com/repos/example/project/releases', + 'curl -X POST --json @payload.json "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"', + 'curl -X DELETE "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/123"', + 'github.rest.repos.createRelease({ owner, repo })', + 'octokit.request("POST /repos/{owner}/{repo}/releases", payload)', + 'github.request({ method: "POST", url: "/repos/{owner}/{repo}/releases", data })', + 'octokit.request({ url: `/repos/${owner}/${repo}/releases/1`, method: "PATCH" })', + `github.request({ method: "POST", data: "${'x'.repeat(600)}", url: "/repos/{owner}/{repo}/releases" })`, + 'github.request({ // publish\n method: "POST", url: "/repos/{owner}/{repo}/releases" })', + 'octokit.request({ /* mutation */ url: "/repos/{owner}/{repo}/releases/1", method: "DELETE" })', + 'octokit.graphql(`mutation { createRelease(input: $input) { release { id } } }`)', + 'Invoke-RestMethod -Method Delete https://api.github.com/repos/example/project/releases/1', + 'uses: ncipollo/release-action@v1', + ]; + const releaseReadFixtures = [ + 'gh api repos/example/project/releases?per_page=100', + 'gh api -H "Accept: application/octet-stream" repos/example/project/releases/assets/1', + 'octokit.request({ method: "GET", url: "/repos/{owner}/{repo}/releases" })', + 'github.request({ method: "GET", url: "/repos/{owner}/{repo}/releases" }); octokit.request({ method: "POST", url: "/repos/{owner}/{repo}/issues" })', + ]; + for (const fixture of releaseMutationFixtures) { + if (!mutatesGitHubReleases(fixture)) fail(`release publisher detector missed: ${fixture}`); + } + for (const fixture of releaseReadFixtures) { + if (mutatesGitHubReleases(fixture)) fail(`release publisher detector rejected read-only API use: ${fixture}`); + } for (const workflow of fs.readdirSync(workflowDir)) { const workflowPath = path.join(workflowDir, workflow); if (!fs.statSync(workflowPath).isFile()) continue; const source = fs.readFileSync(workflowPath, 'utf8'); + if (workflow !== 'release.yml' && mutatesGitHubReleases(source)) { + fail(`${workflow} competes with release.yml as a GitHub Release publisher`); + } if (/actions\/upload-artifact@v(?:[1-5])\b/.test(source)) { fail(`${workflow} uses an upload-artifact release with a deprecated Node runtime`); } diff --git a/scripts/ci-verify-release.sh b/scripts/ci-verify-release.sh index 48286132a..9c583ce6d 100755 --- a/scripts/ci-verify-release.sh +++ b/scripts/ci-verify-release.sh @@ -115,8 +115,25 @@ php -r 'require $argv[1]; echo "autoload OK\n";' "$package_dir/vendor/autoload.p find "$package_dir/app" "$package_dir/installer" "$package_dir/storage/plugins" \ -type f -name '*.php' -print0 | xargs -0 -n1 php -l >/dev/null -for asset in public/assets/main.css public/assets/main.bundle.js public/assets/vendor.bundle.js; do - [ -s "$package_dir/$asset" ] || { echo "missing or empty compiled asset: $asset" >&2; exit 1; } +# Compiled assets plus the historical critical-file list the release contract +# requires in every ZIP (vendored TinyMCE/Swagger payloads are the ones a +# broken `git archive`/checkout most easily drops — .coderabbit.yaml documents +# public/assets/tinymce/models/dom/model.min.js as the canary). +for asset in \ + public/assets/main.css \ + public/assets/main.bundle.js \ + public/assets/vendor.bundle.js \ + public/assets/tinymce/tinymce.min.js \ + public/assets/tinymce/models/dom/model.min.js \ + public/assets/tinymce/themes/silver/theme.min.js \ + public/assets/tinymce/skins/ui/oxide/skin.min.css \ + public/assets/tinymce/icons/default/icons.min.js \ + public/assets/swagger-ui/swagger-ui-bundle.js \ + public/assets/swagger-ui/swagger-ui.css \ + public/index.php \ + app/Support/Updater.php \ + vendor/composer/autoload_real.php; do + [ -s "$package_dir/$asset" ] || { echo "missing or empty critical release file: $asset" >&2; exit 1; } done echo "Release archive verified: pinakes-v${version} (${#entries[@]} entries)" diff --git a/scripts/ci-zap-blocking-alerts.jq b/scripts/ci-zap-blocking-alerts.jq new file mode 100644 index 000000000..3fbf54cca --- /dev/null +++ b/scripts/ci-zap-blocking-alerts.jq @@ -0,0 +1,139 @@ +def catalogue_identifiers: + $catalogue_identifiers[0]; + +def public_catalogue_urls: + $public_catalogue_urls[0]; + +def public_example_identifiers: + $public_example_identifiers[0]; + +def is_known_catalogue_identifier: + . as $identifier + | (catalogue_identifiers | index($identifier)) != null; + +def is_known_public_example_identifier: + . as $identifier + | (public_example_identifiers | index($identifier)) != null; + +def without_query_or_fragment: + sub("[?#].*$"; ""); + +def is_registered_canonical_url: + . as $uri + | ($uri | without_query_or_fragment) as $normalized + | ($normalized | test( + "^http://localhost:8081/(?:(?:it|en|de|fr|da)/)?[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*/[0-9]+$"; + "i" + )) + and ((public_catalogue_urls | index($normalized)) != null); + +def is_static_bibliographic_uri: + test( + "^http://localhost:8081/(?:(?:it|en|de|fr|da)/)?(?:" + + "(?:catalog|catalogo|catalogue|katalog)(?:\\.php)?" + + "|(?:bog-detalje|book-detail|buch-detail|fiche|scheda)\\.php" + + "|(?:bog|book|buch|libro|livre)/[0-9]+(?:/[a-z0-9]+(?:-[a-z0-9]+)*)?" + + "|(?:auteur|author|autor|autore|editeur|editore|forfatter|forlag|genere|genre|publisher|verlag)/[^/?#]+" + + "|api/(?:catalog|catalogo|catalogue|katalog)" + + "|api/bibframe/book/[0-9]+(?:/(?:work|instance))?" + + "|id/instance/[0-9]+" + + "|libri/[0-9]+\\.rda\\.json" + + ")(?:[?#][^#]*)?$"; + "i" + ); + +def is_bibliographic_uri: + is_static_bibliographic_uri or is_registered_canonical_url; + +def is_target_uri: + test("^http://localhost:8081(?:[/?#]|$)"; "i"); + +def is_catalogue_ean_instance: + type == "object" + and has("method") and (.method | type == "string") + and has("evidence") and (.evidence | type == "string") + and has("uri") and (.uri | type == "string") + and has("param") and (.param | type == "string") + and has("attack") and (.attack | type == "string") + and (.method | ascii_upcase) == "GET" + and (.evidence | test("^[0-9]{13}$")) + and ( + ( + (.evidence | is_known_public_example_identifier) + and (.uri | is_target_uri) + ) + or ( + (.evidence | is_known_catalogue_identifier) + and (.uri | is_bibliographic_uri) + ) + ) + and (.param == "") + and (.attack == ""); + +def is_catalogue_ean_false_positive: + ((.pluginid // "") | tostring) == "10062" + and ((.instances // []) | type == "array") + and ((.instances // []) | length > 0) + and ((.instances // []) | all(.[]; is_catalogue_ean_instance)); + +def has_valid_riskcode: + has("riskcode") + and ( + ( + (.riskcode | type) == "number" + and ( + .riskcode == 0 + or .riskcode == 1 + or .riskcode == 2 + or .riskcode == 3 + ) + ) + or ((.riskcode | type) == "string" and (.riskcode | test("^[0-3]$"))) + ); + +def is_valid_zap_alert: + type == "object" and has_valid_riskcode; + +def is_valid_zap_report: + type == "object" + and has("site") + and (.site | type == "array") + and (.site | length > 0) + and ( + .site + | all(.[]; + type == "object" + and has("alerts") + and (.alerts | type == "array") + and (.alerts | all(.[]; is_valid_zap_alert)) + ) + ); + +def has_valid_catalogue_context: + ($catalogue_identifiers | type == "array") + and ($catalogue_identifiers | length == 1) + and (catalogue_identifiers | type == "array") + and (catalogue_identifiers | all(.[]; type == "string" and test("^[0-9]{13}$"))) + and ($public_catalogue_urls | type == "array") + and ($public_catalogue_urls | length == 1) + and (public_catalogue_urls | type == "array") + and (public_catalogue_urls | all(.[]; type == "string")) + and ($public_example_identifiers | type == "array") + and ($public_example_identifiers | length == 1) + and (public_example_identifiers | type == "array") + and (public_example_identifiers | all(.[]; type == "string" and test("^[0-9]{13}$"))); + +if type != "array" or length != 1 then + error("expected exactly one OWASP ZAP JSON document") +elif (has_valid_catalogue_context | not) then + error("invalid catalogue context for OWASP ZAP filtering") +elif (.[0] | is_valid_zap_report | not) then + error("invalid or incomplete OWASP ZAP JSON report") +else + .[0] + | [ + .site[].alerts[] + | select((.riskcode | tonumber) >= 2) + | select(is_catalogue_ean_false_positive | not) + ] +end diff --git a/scripts/ci-zap-public-isbn-examples.jq b/scripts/ci-zap-public-isbn-examples.jq new file mode 100644 index 000000000..f44bea7b9 --- /dev/null +++ b/scripts/ci-zap-public-isbn-examples.jq @@ -0,0 +1,31 @@ +def digit_at($value; $index): + $value[$index:$index + 1] | tonumber; + +def has_valid_isbn13_checksum: + . as $isbn + | ( + [ + range(0; 12) as $index + | digit_at($isbn; $index) + * (if ($index % 2) == 0 then 1 else 3 end) + ] + | add + ) as $weighted_sum + | digit_at($isbn; 12) == ((10 - ($weighted_sum % 10)) % 10); + +if type != "object" then + error("canonical locale must be a JSON object") +else + [ + keys[] + | select(test("^es\\. (?:978|979)[0-9]{10}$")) + | sub("^es\\. "; "") + ] as $examples + | if ($examples | length) == 0 then + error("canonical locale contains no explicit ISBN-13 example keys") + elif ($examples | all(.[]; has_valid_isbn13_checksum)) | not then + error("canonical locale contains an ISBN-13 example with an invalid checksum") + else + $examples | unique + end +end diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 6351fa4de..4d8d83596 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -1,585 +1,291 @@ -#!/bin/bash -set -e - -# ============================================================================ -# Pinakes Release Creation Script -# ============================================================================ -# This script automates the ENTIRE release process to prevent errors. -# NEVER create releases manually - ALWAYS use this script! -# -# Usage: ./scripts/create-release.sh 0.4.8 -# ============================================================================ - -VERSION=$1 - -if [ -z "$VERSION" ]; then - echo "❌ ERROR: Version number required" - echo "Usage: ./scripts/create-release.sh 0.4.8 (stable release)" - echo " ./scripts/create-release.sh 0.7.15-rc.1 (release candidate / prerelease)" +#!/usr/bin/env bash +# Create a Pinakes release by delegating all artifact production and publishing +# to .github/workflows/release.yml. This script only performs local/source +# preflight checks, pushes the release tag, waits for the workflow, and verifies +# the published release contract. + +set -euo pipefail + +readonly REPOSITORY="fabiodalez-dev/Pinakes" +readonly RELEASE_WORKFLOW="release.yml" +readonly GITHUB_API_VERSION="2026-03-10" +readonly WORKFLOW_DISCOVERY_ATTEMPTS=24 +readonly WORKFLOW_DISCOVERY_INTERVAL=5 + +usage() { + cat <<'USAGE' +Usage: ./scripts/create-release.sh X.Y.Z [--yes] + ./scripts/create-release.sh X.Y.Z-rc.N [--yes] + +The script does not build or upload release assets locally. It validates the +source, creates and pushes an annotated tag, waits for the Verified Release +workflow, and verifies the assets published by that workflow. +USAGE +} + +fail() { + printf 'ERROR: %s\n' "$*" >&2 exit 1 -fi - -# SECURITY: VERSION is interpolated into gh/jq/tag commands below. Whitelist the -# shape (X.Y.Z, optional 4th segment, optional -prerelease tail) so a value with -# shell metacharacters can never reach those commands. Matches the project's -# 3- and 4-segment scheme (e.g. 0.7.20, 0.4.9.9) plus SemVer pre-release ids. -if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[0-9A-Za-z.]+)?$'; then - echo "❌ ERROR: invalid version '$VERSION'." - echo " Expected X.Y.Z, X.Y.Z.W, or X.Y.Z-rc.N (digits, dots, and a -prerelease tail only)." - exit 1 -fi +} -# Detect a release candidate / prerelease: any SemVer pre-release identifier, -# i.e. a hyphen in the version (0.7.15-rc.1, 0.8.0-beta.2, …). RC packages are -# published as GitHub *prereleases* so the /releases/latest endpoint skips them -# and the in-app updater keeps them hidden unless a developer opts into the RC -# channel via env (UPDATER_ALLOW_PRERELEASE=1 or UPDATER_CHANNEL=rc). See updater.md. -IS_PRERELEASE=false -if [[ "$VERSION" == *-* ]]; then - IS_PRERELEASE=true -fi +info() { + printf '==> %s\n' "$*" +} -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}Creating Pinakes Release v${VERSION}${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" - -# ============================================================================ -# STEP 1: Verify we're on main branch and up to date -# ============================================================================ -echo -e "${YELLOW}[1/9] Verifying git status...${NC}" - -BRANCH=$(git branch --show-current) -if [ "$IS_PRERELEASE" = true ]; then - # RC/prerelease packages are routinely cut from a feature/release branch, - # never from main. The tag is created against this branch (see STEP 7), so - # the branch MUST already be pushed to origin. - echo -e "${YELLOW}⚠ Prerelease ${VERSION}: branch-must-be-main check relaxed (currently on: $BRANCH).${NC}" - if [ -z "$(git ls-remote --heads origin "$BRANCH" 2>/dev/null)" ]; then - echo -e "${RED}❌ ERROR: branch '$BRANCH' is not on origin. Push it first: git push -u origin $BRANCH${NC}" - exit 1 - fi -elif [ "$BRANCH" != "main" ]; then - echo -e "${RED}❌ ERROR: Must be on main branch (currently on: $BRANCH)${NC}" +if [[ $# -lt 1 || $# -gt 2 ]]; then + usage exit 1 fi -if [ -n "$(git status --porcelain)" ]; then - echo -e "${RED}❌ ERROR: Working directory not clean. Commit or stash changes first.${NC}" - git status --short - exit 1 +VERSION="$1" +ASSUME_YES=false +if [[ $# -eq 2 ]]; then + [[ "$2" == "--yes" ]] || fail "unknown option '$2'" + ASSUME_YES=true fi -echo -e "${GREEN}✓ On main branch, working directory clean${NC}" -echo "" - -# ============================================================================ -# STEP 2: Verify version.json has been updated -# ============================================================================ -echo -e "${YELLOW}[2/9] Checking version.json...${NC}" - -CURRENT_VERSION=$(jq -r '.version' version.json) -if [ "$CURRENT_VERSION" != "$VERSION" ]; then - echo -e "${RED}❌ ERROR: version.json has version $CURRENT_VERSION but you specified $VERSION${NC}" - echo "Update version.json first and commit it." - exit 1 +# Keep this grammar identical to scripts/ci-verify-release-source.sh. A tag +# accepted here but rejected in CI would leave an unusable remote release tag. +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then + fail "invalid version '$VERSION'; expected X.Y.Z or X.Y.Z-(alpha|beta|rc).N" fi -echo -e "${GREEN}✓ version.json is correct: $VERSION${NC}" -echo "" - -# ============================================================================ -# STEP 3: Verify schema behavior and autoloader integrity -# ============================================================================ -echo -e "${YELLOW}[3/9] Verifying schema behavior and clean autoloader...${NC}" - -CI_STRICT_TESTS=1 bash scripts/verify-schema.sh - -# PHPStan was removed from composer.json — autoloader should never reference it -if grep -q "phpstan" vendor/composer/autoload_files.php 2>/dev/null; then - echo -e "${RED}❌ ERROR: vendor/composer still references phpstan!${NC}" - echo -e "${RED} Run: composer install --no-dev --optimize-autoloader${NC}" - exit 1 -fi - -if grep -q "phpstan" vendor/composer/autoload_static.php 2>/dev/null; then - echo -e "${RED}❌ ERROR: vendor/composer/autoload_static.php references phpstan!${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Schema gate and autoloader checks passed${NC}" -echo "" - -# ============================================================================ -# STEP 5: Create release ZIP with git archive -# ============================================================================ -echo -e "${YELLOW}[5/9] Creating release ZIP...${NC}" - -ZIPFILE="pinakes-v${VERSION}.zip" -rm -f "$ZIPFILE" "${ZIPFILE}.sha256" - -git archive --format=zip --prefix="pinakes-v${VERSION}/" -o "$ZIPFILE" HEAD - -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ERROR: git archive failed${NC}" - exit 1 -fi - -SIZE=$(ls -lh "$ZIPFILE" | awk '{print $5}') -SIZE_BYTES=$(stat -f%z "$ZIPFILE" 2>/dev/null || stat -c%s "$ZIPFILE" 2>/dev/null || echo 0) -MAX_SIZE=$((50 * 1024 * 1024)) # 50MB — normal release is ~25-35MB - -if [ "$SIZE_BYTES" -gt "$MAX_SIZE" ]; then - echo -e "${RED}❌ ERROR: ZIP is suspiciously large ($SIZE). Expected <50MB.${NC}" - echo -e "${RED} Likely contains dev files (frontend/, docs/, releases/, etc.)${NC}" - echo -e "${RED} Check .gitattributes export-ignore rules.${NC}" - rm -f "$ZIPFILE" - exit 1 -fi - -echo -e "${GREEN}✓ Release ZIP created: $ZIPFILE ($SIZE)${NC}" -echo "" - -# ============================================================================ -# STEP 5.5: Verify ZIP contents (critical files check) -# ============================================================================ -echo -e "${YELLOW}[5.5/9] Verifying ZIP contents...${NC}" - -VERIFY_DIR=$(mktemp -d) -unzip -q "$ZIPFILE" -d "$VERIFY_DIR" - -# List of critical files that MUST be in the ZIP -CRITICAL_FILES=( - "public/assets/tinymce/tinymce.min.js" - "public/assets/tinymce/models/dom/model.min.js" - "public/assets/tinymce/themes/silver/theme.min.js" - "public/assets/tinymce/skins/ui/oxide/skin.min.css" - "public/assets/tinymce/icons/default/icons.min.js" - "public/assets/swagger-ui/swagger-ui-bundle.js" - "public/assets/swagger-ui/swagger-ui.css" - "public/index.php" - "app/Support/Updater.php" - "version.json" - "vendor/composer/autoload_real.php" -) - -# Bundled plugins that MUST be in the ZIP, derived from the runtime source. -BUNDLED_OUTPUT=$(php scripts/list-source-expectations.php plugins) -BUNDLED_PLUGINS=() -while IFS= read -r plugin; do - [ -n "$plugin" ] && BUNDLED_PLUGINS+=("$plugin") -done <<< "$BUNDLED_OUTPUT" -if [ "${#BUNDLED_PLUGINS[@]}" -eq 0 ]; then - echo -e "${RED} ✗ BundledPlugins::LIST produced an empty plugin list${NC}" - exit 1 -fi -EXPECTED_PLUGIN_COUNT=${#BUNDLED_PLUGINS[@]} - -MISSING=0 -for file in "${CRITICAL_FILES[@]}"; do - FULL_PATH="$VERIFY_DIR/pinakes-v${VERSION}/$file" - if [ ! -f "$FULL_PATH" ]; then - echo -e "${RED} ✗ MISSING: $file${NC}" - MISSING=$((MISSING + 1)) - fi -done - -# Verify bundled plugins are present -for plugin in "${BUNDLED_PLUGINS[@]}"; do - PLUGIN_JSON="$VERIFY_DIR/pinakes-v${VERSION}/storage/plugins/$plugin/plugin.json" - if [ ! -f "$PLUGIN_JSON" ]; then - echo -e "${RED} ✗ MISSING PLUGIN: storage/plugins/$plugin/plugin.json${NC}" - MISSING=$((MISSING + 1)) - fi -done - -PACKAGED_PLUGIN_COUNT=$(find "$VERIFY_DIR/pinakes-v${VERSION}/storage/plugins" \ - -mindepth 2 -maxdepth 2 -name plugin.json -print 2>/dev/null | wc -l | tr -d ' ') -if [ "$PACKAGED_PLUGIN_COUNT" -ne "$EXPECTED_PLUGIN_COUNT" ]; then - echo -e "${RED} ✗ PLUGIN SET MISMATCH: package has $PACKAGED_PLUGIN_COUNT manifests, BundledPlugins::LIST declares $EXPECTED_PLUGIN_COUNT${NC}" - MISSING=$((MISSING + 1)) +readonly TAG_NAME="v${VERSION}" +IS_PRERELEASE=false +if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE=true fi -# Verify scraping-pro is NOT in ZIP (premium plugin, not bundled) -if [ -d "$VERIFY_DIR/pinakes-v${VERSION}/storage/plugins/scraping-pro" ]; then - echo -e "${RED} ✗ scraping-pro found in ZIP (should NOT be bundled — it's premium)${NC}" - MISSING=$((MISSING + 1)) -fi +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" -# Verify dev-only directories are NOT in ZIP (export-ignore in .gitattributes) -for devdir in frontend docs tests test .github internal; do - if [ -d "$VERIFY_DIR/pinakes-v${VERSION}/$devdir" ]; then - echo -e "${RED} ✗ $devdir/ found in ZIP (should be excluded via .gitattributes export-ignore)${NC}" - MISSING=$((MISSING + 1)) - fi +info "Checking release prerequisites" +for command_name in git gh jq composer npm; do + command -v "$command_name" >/dev/null 2>&1 \ + || fail "required command not found: $command_name" done +gh auth status >/dev/null 2>&1 || fail "GitHub CLI is not authenticated; run 'gh auth login'" + +resolved_repository="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +resolved_repository_lc="$(printf '%s' "$resolved_repository" | tr '[:upper:]' '[:lower:]')" +expected_repository_lc="$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]')" +[[ "$resolved_repository_lc" == "$expected_repository_lc" ]] \ + || fail "current repository is '$resolved_repository', expected '$REPOSITORY'" + +# GitHub's API digest changes whenever a mutable asset is replaced, so digest +# verification alone cannot pin what users download later. Refuse to create the +# tag unless future releases in this repository are locked at publication time. +if ! immutable_policy="$(gh api \ + -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ + "repos/${REPOSITORY}/immutable-releases" 2>/dev/null)"; then + fail "cannot verify release immutability; authenticate with repository administration read access" +fi +if ! jq -e '.enabled == true' >/dev/null <<<"$immutable_policy"; then + fail "immutable releases are disabled for ${REPOSITORY}; enable release immutability before creating ${TAG_NAME}" +fi +info "Release immutability is enabled" + +# Draft releases do not lock their tags yet. Keep version tags non-movable from +# creation onward so the verified commit cannot be swapped between build and +# publication. The workflow still rechecks the resolved SHA at every boundary. +# --slurp + first: con `set -o pipefail`, chiudere la pipe con `head -n 1` +# mentre gh sta ancora paginando termina gh con SIGPIPE (exit 141) e lo script +# muore senza il messaggio di errore previsto. `gh api` rende inoltre `--slurp` +# e `--jq` mutuamente esclusivi, quindi il filtro deve essere un jq esterno che +# consuma per intero il documento paginato. +release_tag_ruleset_id="$(gh api "repos/${REPOSITORY}/rulesets?targets=tag&per_page=100" --paginate --slurp \ + | jq -r '[.[][] | select(.name == "Protect immutable release tags") | .id] | first // empty')" +[[ -n "$release_tag_ruleset_id" ]] \ + || fail "active release-tag protection ruleset is missing" +release_tag_ruleset="$(gh api "repos/${REPOSITORY}/rulesets/${release_tag_ruleset_id}")" +if ! jq -e ' + .target == "tag" + and .enforcement == "active" + and (.conditions.ref_name.include | index("refs/tags/v*") != null) + and (.conditions.ref_name.exclude | length == 0) + and ([.rules[].type] | index("deletion") != null and index("non_fast_forward") != null) + and ((.bypass_actors // []) | length == 0) +' >/dev/null <<<"$release_tag_ruleset"; then + fail "release-tag ruleset must block deletion/non-fast-forward updates for refs/tags/v* without bypasses" +fi +info "Release tags are protected from deletion and movement" + +[[ -z "$(git status --porcelain)" ]] \ + || fail "working tree is not clean; commit or stash every tracked and untracked change" + +head_sha="$(git rev-parse --verify HEAD)" +branch="$(git branch --show-current)" +[[ -n "$branch" ]] || fail "detached HEAD is not a valid release source" + +current_version="$(jq -er '.version | select(type == "string" and length > 0)' version.json)" +[[ "$current_version" == "$VERSION" ]] \ + || fail "version.json contains '$current_version', expected '$VERSION'" +grep -Fqx "## [${VERSION}]" CHANGELOG.md \ + || fail "CHANGELOG.md has no exact '## [${VERSION}]' release section" + +if git show-ref --verify --quiet "refs/tags/${TAG_NAME}"; then + fail "local tag ${TAG_NAME} already exists" +fi +if [[ -n "$(git ls-remote --tags origin "refs/tags/${TAG_NAME}")" ]]; then + fail "remote tag ${TAG_NAME} already exists" +fi +existing_release_id="$(gh api "repos/${REPOSITORY}/releases?per_page=100" --paginate --slurp \ + | jq -r "[.[][] | select(.tag_name == \"${TAG_NAME}\") | .id] | first // empty")" +[[ -z "$existing_release_id" ]] || fail "GitHub release ${TAG_NAME} already exists" + +# The workflow repeats this source gate after the tag push. Running the exact +# same policy before the irreversible operation prevents avoidable orphan tags. +if [[ "$IS_PRERELEASE" == false ]]; then + [[ "$branch" == "main" ]] || fail "stable releases must be created from main (current: $branch)" + git fetch --no-tags origin main + origin_main_sha="$(git rev-parse --verify refs/remotes/origin/main)" + [[ "$head_sha" == "$origin_main_sha" ]] \ + || fail "local main ($head_sha) is not exactly origin/main ($origin_main_sha)" +fi + +release_token="$(gh auth token)" +TAG_NAME="$TAG_NAME" \ +GITHUB_SHA="$head_sha" \ +GITHUB_REPOSITORY="$REPOSITORY" \ +GH_TOKEN="$release_token" \ + bash scripts/ci-verify-release-source.sh +unset release_token + +info "Running local release-policy and schema preflight" +composer validate --strict +npm run test:ci-policy +CI_STRICT_TESTS=1 bash scripts/verify-schema.sh -# Verify no PHPStan in autoloader -PHPSTAN_COUNT=$(grep -c "phpstan" "$VERIFY_DIR/pinakes-v${VERSION}/vendor/composer/autoload_real.php" || true) -if [ "$PHPSTAN_COUNT" -gt 0 ]; then - echo -e "${RED} ✗ PHPStan found in autoload_real.php ($PHPSTAN_COUNT references)${NC}" - MISSING=$((MISSING + 1)) -fi - -# Detect symlinks in the ZIP via zipinfo metadata (macOS `unzip` would recreate -# them, but PHP ZipArchive on Linux extracts as 22-byte regular files → Updater -# then fails copy(file, existing_dir). Broke v0.5.4 manual upgrade in prod.) -# zipinfo long-format symlink lines look like: -# lrwxrwxrwx 2.0 unx 22 b- stor ... -> -# We want (the offending repo path the maintainer must fix), which is -# the field immediately before "->" — NOT $NF, which would be the target. -SYMLINKS_IN_ZIP=$(zipinfo "$ZIPFILE" 2>/dev/null \ - | awk '/^l/ { for (i=1; i<=NF; i++) if ($i == "->") { print $(i-1); break } }') -if [ -n "$SYMLINKS_IN_ZIP" ]; then - echo -e "${RED} ✗ Symlinks in ZIP — will break Updater.copyDirectory() in production:${NC}" - echo "$SYMLINKS_IN_ZIP" | sed 's/^/ /' - echo -e "${RED} Fix: replace the symlink in the repo with a real directory containing the files.${NC}" - MISSING=$((MISSING + 1)) -fi - -# Verify version matches -ZIP_VERSION=$(jq -r '.version' "$VERIFY_DIR/pinakes-v${VERSION}/version.json") -if [ "$ZIP_VERSION" != "$VERSION" ]; then - echo -e "${RED} ✗ version.json in ZIP has $ZIP_VERSION (expected $VERSION)${NC}" - MISSING=$((MISSING + 1)) -fi - -rm -rf "$VERIFY_DIR" - -if [ "$MISSING" -gt 0 ]; then - echo -e "${RED}❌ ERROR: ZIP verification failed ($MISSING problems). Aborting release.${NC}" - rm -f "$ZIPFILE" - exit 1 -fi - -echo -e "${GREEN}✓ ZIP verified: all critical files present, ${#BUNDLED_PLUGINS[@]} bundled plugins, no PHPStan, version correct${NC}" -echo "" - -# ============================================================================ -# STEP 6: Generate SHA256 checksum -# ============================================================================ -echo -e "${YELLOW}[6/9] Generating SHA256 checksum...${NC}" - -shasum -a 256 "$ZIPFILE" > "${ZIPFILE}.sha256" - -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ERROR: checksum generation failed${NC}" - exit 1 -fi - -CHECKSUM=$(cat "${ZIPFILE}.sha256" | awk '{print $1}') -echo -e "${GREEN}✓ Checksum: $CHECKSUM${NC}" -echo "" - -# ============================================================================ -# STEP 7: Create GitHub release -# ============================================================================ -echo -e "${YELLOW}[7/9] Creating GitHub release v${VERSION}...${NC}" - -# Check if release already exists -if gh release view "v${VERSION}" >/dev/null 2>&1; then - echo -e "${YELLOW}⚠ Release v${VERSION} already exists. Deleting and recreating...${NC}" - gh release delete "v${VERSION}" --yes -fi - -# SECURITY (draft → upload → verify → publish): create the release as a DRAFT so -# it is never visible/installable while we upload and verify its assets. It is -# published only AFTER the remote ZIP is proven to match the local one (STEP 9.6). -# --target pins the commit the tag will point at when the draft is published. -if [ "$IS_PRERELEASE" = true ]; then - # --prerelease (NOT --latest): GitHub excludes prereleases from - # /releases/latest, so the default updater never offers this package. - gh release create "v${VERSION}" \ - --title "Pinakes v${VERSION} (Release Candidate)" \ - --generate-notes \ - --prerelease \ - --draft \ - --target "$BRANCH" -else - gh release create "v${VERSION}" \ - --title "Pinakes v${VERSION}" \ - --generate-notes \ - --draft \ - --target "$BRANCH" -fi - -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ERROR: GitHub release creation failed${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ GitHub DRAFT release created (published only after verification)${NC}" -echo "" - -# ============================================================================ -# STEP 8: Upload ZIP and checksum to release -# ============================================================================ -echo -e "${YELLOW}[8/9] Uploading files to GitHub release...${NC}" - -# Build the asset list — always ZIP + its checksum, plus optional patch files -# (post-install-patch.php / pre-update-patch.php) when present in repo root. -# Those patch files are release-specific hotfixes dropped next to the script -# by the maintainer; they're gitignored so they don't bleed into main. -UPLOAD_ASSETS=("$ZIPFILE" "${ZIPFILE}.sha256") -for PATCH in post-install-patch.php pre-update-patch.php; do - if [ -f "$PATCH" ]; then - # Publish a fresh checksum next to the patch. NOTE: the hardened Updater - # verifies the patch from the GitHub asset *digest* (computed server-side - # over TLS), NOT this sidecar — the .sha256 fallback was removed. The - # sidecar is kept only as a convenience for manual verification. - shasum -a 256 "$PATCH" > "${PATCH}.sha256" - UPLOAD_ASSETS+=("$PATCH" "${PATCH}.sha256") - echo -e "${YELLOW} + Attaching $PATCH (+ checksum)${NC}" +[[ "$(git rev-parse --verify HEAD)" == "$head_sha" ]] \ + || fail "HEAD changed during preflight" +[[ -z "$(git status --porcelain)" ]] \ + || fail "preflight modified the working tree" + +if [[ "$ASSUME_YES" == false ]]; then + printf 'Push annotated tag %s at %s and start the release workflow? [y/N] ' \ + "$TAG_NAME" "$head_sha" + read -r answer + [[ "$answer" == "y" || "$answer" == "Y" ]] || fail "release cancelled" +fi + +# A failed release may leave an Actions run behind after its tag/draft is +# deliberately cleaned up. Snapshot the current workflow high-water mark so a +# retry can never attach to that stale run while the new push is still being +# indexed by GitHub. +workflow_run_floor="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow "$RELEASE_WORKFLOW" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq '[.[].databaseId] | max // 0')" +[[ "$workflow_run_floor" =~ ^[0-9]+$ ]] \ + || fail "could not snapshot the existing release workflow run ids" + +info "Creating and pushing ${TAG_NAME}" +git tag -a "$TAG_NAME" "$head_sha" -m "Pinakes ${TAG_NAME}" +if ! git push origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}"; then + fail "tag push failed; inspect refs/tags/${TAG_NAME} locally and remotely before retrying" +fi + +info "Waiting for the Verified Release workflow to start" +run_id="" +run_url="" +for ((attempt = 1; attempt <= WORKFLOW_DISCOVERY_ATTEMPTS; attempt++)); do + runs_json="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow "$RELEASE_WORKFLOW" \ + --event push \ + --commit "$head_sha" \ + --limit 20 \ + --json databaseId,headBranch,headSha,url)" + run_id="$(jq -r --arg tag "$TAG_NAME" --arg sha "$head_sha" --argjson floor "$workflow_run_floor" \ + '[.[] | select(.headBranch == $tag and .headSha == $sha and .databaseId > $floor)] + | sort_by(.databaseId) | last | .databaseId // empty' \ + <<<"$runs_json")" + run_url="$(jq -r --arg tag "$TAG_NAME" --arg sha "$head_sha" --argjson floor "$workflow_run_floor" \ + '[.[] | select(.headBranch == $tag and .headSha == $sha and .databaseId > $floor)] + | sort_by(.databaseId) | last | .url // empty' \ + <<<"$runs_json")" + if [[ -n "$run_id" ]]; then + break fi + printf ' workflow not visible yet (%d/%d)\n' "$attempt" "$WORKFLOW_DISCOVERY_ATTEMPTS" + sleep "$WORKFLOW_DISCOVERY_INTERVAL" done -gh release upload "v${VERSION}" "${UPLOAD_ASSETS[@]}" --clobber - -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ERROR: File upload failed${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Files uploaded to release${NC}" -echo "" - -# ============================================================================ -# STEP 9: Verify release is complete -# ============================================================================ -echo -e "${YELLOW}[9/9] Verifying release...${NC}" - -ASSETS=$(gh release view "v${VERSION}" --json assets --jq '.assets | length') - -if [ "$ASSETS" -lt 2 ]; then - echo -e "${RED}❌ ERROR: Release has only $ASSETS assets (expected at least 2)${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Release has $ASSETS assets${NC}" -echo "" - -# ============================================================================ -# STEP 9.4: INTEGRITY-SOURCE GUARD (supports the hardened in-app updater) -# The updater REFUSES to install a package it cannot verify. Since the sidecar -# fallback was removed (see Updater.php — "The '.sha256' sidecar fallback was -# removed"), integrity comes EXCLUSIVELY from the GitHub asset "digest" -# (sha256:...), which GitHub computes server-side over TLS. A release without a -# digest would wedge the upgrade chain for every install, so fail the publish -# here rather than discover it at users' update time. -# ============================================================================ -ZIP_ASSET_NAME="pinakes-v${VERSION}.zip" -# Resolve the DRAFT release's numeric id by tag_name. Drafts have no git tag yet, -# so `gh release view ` / releases/tags/ are unreliable for them — list -# releases (which includes drafts) and match tag_name, then read assets via the -# release id. Same draft-safe pattern as STEP 9.5. -GUARD_RELEASE_ID=$(gh api "repos/fabiodalez-dev/Pinakes/releases" --paginate \ - --jq ".[] | select(.tag_name == \"v${VERSION}\") | .id" 2>/dev/null | head -1) -if [ -z "$GUARD_RELEASE_ID" ]; then - echo -e "${RED}❌ ERROR: could not resolve the draft release id for v${VERSION} (integrity guard).${NC}" - exit 1 -fi - -# GitHub computes the asset "digest" ASYNCHRONOUSLY after upload (like size), so -# poll briefly rather than fail on the first miss. -HAS_DIGEST="no" -for attempt in 1 2 3 4 5 6; do - GUARD_ASSET_META=$(gh api "repos/fabiodalez-dev/Pinakes/releases/${GUARD_RELEASE_ID}/assets" \ - --jq ".[] | select(.name == \"${ZIP_ASSET_NAME}\")" 2>/dev/null || echo "") - # Match the Updater's runtime contract (isValidSha256): require the FULL - # "sha256:" + 64 hex chars, not just the prefix — a malformed digest like - # "sha256:NOTHEX" would pass a prefix check here but be rejected as 'invalid' - # by the updater at install time. - HAS_DIGEST=$(printf '%s' "$GUARD_ASSET_META" | jq -r 'if (.digest // "") | test("^sha256:[A-Fa-f0-9]{64}$") then "yes" else "no" end' 2>/dev/null || echo "no") - [ "$HAS_DIGEST" = "yes" ] && break - echo -e "${YELLOW} digest not computed yet (attempt $attempt/6), waiting 10s...${NC}" - sleep 10 -done +[[ -n "$run_id" ]] || fail "no ${RELEASE_WORKFLOW} run appeared for ${TAG_NAME}; remote tag was not removed" -if [ "$HAS_DIGEST" != "yes" ]; then - echo -e "${RED}❌ ERROR: release asset ${ZIP_ASSET_NAME} exposes no API sha256 digest after polling.${NC}" - echo -e "${RED} The hardened updater verifies integrity from the digest ONLY (sidecar fallback removed).${NC}" - echo -e "${RED} GitHub usually computes the digest within seconds — re-check the asset or re-upload.${NC}" - exit 1 -fi -echo -e "${GREEN}✓ Integrity source present (digest=${HAS_DIGEST}) — hardened updater can verify this package${NC}" -echo "" - -# ============================================================================ -# STEP 9.5: VERIFY THE ACTUAL REMOTE ZIP MATCHES THE LOCAL ZIP -# ============================================================================ -# HARD RULE (see updater.md §ABSOLUTE RULE): "upload succeeded" is NOT enough. -# On 2026-04-22 two separate failure modes corrupted the shipped ZIP: -# 1) gh release upload produced a truncated remote artifact (v0.5.9.2). -# 2) A hidden GitHub Actions workflow (release.yml) rebuilt the ZIP via -# bin/build-release.sh and overwrote the asset AFTER the upload — -# verification hitting the CDN saw the cached correct file briefly, -# then the CDN invalidated and users downloaded the workflow's broken -# ZIP (v0.5.9.3, reported by HansUwe52). -# Mitigations: -# - release.yml is now renamed .disabled so it does not race our upload. -# - This step fetches via the GitHub API (asset ID + octet-stream Accept), -# bypassing the CDN entirely. -# - It also polls for up to 90 seconds to catch any asynchronous overwrite -# from a rogue workflow that might slip in. -# - Sanity-check: uploader MUST be the current gh user, NOT github-actions[bot]. -echo -e "${YELLOW}[9.5/9] Verifying REMOTE ZIP matches local ZIP (via API, not CDN)...${NC}" - -REMOTE_VERIFY_DIR=$(mktemp -d) -REMOTE_ZIP="$REMOTE_VERIFY_DIR/remote.zip" - -LOCAL_SHA=$(shasum -a 256 "$ZIPFILE" | awk '{print $1}') -LOCAL_PLUGIN_COUNT=$(unzip -l "$ZIPFILE" 2>/dev/null | grep -cE "storage/plugins/[^/]+/plugin\.json$" || true) -if [ "$LOCAL_PLUGIN_COUNT" -ne "$EXPECTED_PLUGIN_COUNT" ]; then - echo -e "${RED}❌ ERROR: local ZIP has $LOCAL_PLUGIN_COUNT plugin manifests; BundledPlugins::LIST declares $EXPECTED_PLUGIN_COUNT${NC}" - rm -rf "$REMOTE_VERIFY_DIR" - exit 1 +info "Following workflow run ${run_id}: ${run_url}" +if ! gh run watch "$run_id" --repo "$REPOSITORY" --exit-status --interval 15; then + gh run view "$run_id" --repo "$REPOSITORY" --log-failed || true + fail "release workflow failed; ${TAG_NAME} remains tagged but no release was accepted" fi -GH_USER=$(gh api user --jq .login 2>/dev/null || echo "unknown") - -# Resolve the DRAFT release's numeric id by tag_name: drafts have no git tag yet, -# so the tag-based API (releases/tags/v…) does not work — list releases (which -# includes drafts) and match on tag_name. Assets are then read via the release id. -RELEASE_ID=$(gh api "repos/fabiodalez-dev/Pinakes/releases" --paginate \ - --jq ".[] | select(.tag_name == \"v${VERSION}\") | .id" 2>/dev/null | head -1) -if [ -z "$RELEASE_ID" ]; then - echo -e "${RED}❌ ERROR: could not resolve the draft release id for v${VERSION}${NC}" - rm -rf "$REMOTE_VERIFY_DIR" - exit 1 -fi - -# Poll for up to 90s so a slow/async workflow override would also be caught. -ATTEMPTS=0 -MAX_ATTEMPTS=9 -MATCH=0 -while [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; do - ATTEMPTS=$((ATTEMPTS + 1)) - - # 1. Look up the asset's numeric ID + metadata via the release id (draft-safe). - ASSET_META=$(gh api "repos/fabiodalez-dev/Pinakes/releases/${RELEASE_ID}/assets" \ - --jq ".[] | select(.name == \"pinakes-v${VERSION}.zip\") | {id, size, uploader: .uploader.login}" 2>/dev/null || echo "") - if [ -z "$ASSET_META" ]; then - echo -e "${YELLOW} attempt $ATTEMPTS/$MAX_ATTEMPTS: asset not listed yet, retrying in 10s${NC}" - sleep 10 - continue - fi - - ASSET_ID=$(echo "$ASSET_META" | jq -r '.id') - REMOTE_UPLOADER=$(echo "$ASSET_META" | jq -r '.uploader') - - # 2. Fail loudly if the uploader is a bot — means a workflow hijacked the release - if [ "$REMOTE_UPLOADER" = "github-actions[bot]" ]; then - echo -e "${RED}❌ CRITICAL: release asset uploader is github-actions[bot]${NC}" - echo -e "${RED} A GitHub Actions workflow overwrote our upload.${NC}" - echo -e "${RED} Expected uploader: $GH_USER${NC}" - echo -e "${RED} Check for rogue workflows in .github/workflows/${NC}" - rm -rf "$REMOTE_VERIFY_DIR" - exit 1 - fi - - # 3. Download via the API (bypasses CDN, always returns current content) - if ! gh api "repos/fabiodalez-dev/Pinakes/releases/assets/${ASSET_ID}" \ - -H "Accept: application/octet-stream" > "$REMOTE_ZIP" 2>/dev/null; then - echo -e "${YELLOW} attempt $ATTEMPTS/$MAX_ATTEMPTS: API download failed, retrying${NC}" - sleep 10 - continue - fi - - REMOTE_SHA=$(shasum -a 256 "$REMOTE_ZIP" | awk '{print $1}') - REMOTE_PLUGIN_COUNT=$(unzip -l "$REMOTE_ZIP" 2>/dev/null | grep -cE "storage/plugins/[^/]+/plugin\.json$" || true) - if [ "$LOCAL_SHA" = "$REMOTE_SHA" ] && [ "$REMOTE_PLUGIN_COUNT" = "$EXPECTED_PLUGIN_COUNT" ]; then - MATCH=1 - break - fi +remote_tag_commit="$(gh api "repos/${REPOSITORY}/commits/${TAG_NAME}" --jq '.sha')" +[[ "$remote_tag_commit" == "$head_sha" ]] \ + || fail "remote ${TAG_NAME} moved from ${head_sha} to ${remote_tag_commit} during release" - echo -e "${YELLOW} attempt $ATTEMPTS/$MAX_ATTEMPTS: mismatch (sha local=$LOCAL_SHA remote=$REMOTE_SHA, plugins local=$LOCAL_PLUGIN_COUNT remote=$REMOTE_PLUGIN_COUNT), retrying${NC}" +info "Verifying the published release and its workflow-owned assets" +release_json="" +for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + release_json="$(gh api "repos/${REPOSITORY}/releases/tags/${TAG_NAME}")" + zip_digest="$(jq -r --arg name "pinakes-v${VERSION}.zip" \ + '.assets[] | select(.name == $name) | .digest // empty' <<<"$release_json")" + [[ "$zip_digest" =~ ^sha256:[0-9a-fA-F]{64}$ ]] && break + printf ' ZIP digest not ready yet (%d/12)\n' "$attempt" sleep 10 done -if [ "$MATCH" != "1" ]; then - echo -e "${RED}❌ CRITICAL: REMOTE ZIP DOES NOT MATCH LOCAL ZIP after ${MAX_ATTEMPTS} attempts${NC}" - echo -e "${RED} local: $LOCAL_SHA ($LOCAL_PLUGIN_COUNT plugins, $(wc -c < "$ZIPFILE") bytes)${NC}" - echo -e "${RED} remote: $REMOTE_SHA ($REMOTE_PLUGIN_COUNT plugins, $(wc -c < "$REMOTE_ZIP") bytes, uploader=$REMOTE_UPLOADER)${NC}" - echo -e "${RED}DO NOT ANNOUNCE THIS RELEASE. Delete it and retry:${NC}" - echo -e "${RED} gh release delete v${VERSION} --yes${NC}" - echo -e "${RED} ./scripts/create-release.sh ${VERSION}${NC}" - rm -rf "$REMOTE_VERIFY_DIR" - exit 1 -fi - -rm -rf "$REMOTE_VERIFY_DIR" -echo -e "${GREEN}✓ Remote ZIP matches local via API (SHA256 $LOCAL_SHA, $REMOTE_PLUGIN_COUNT plugins, uploader=$REMOTE_UPLOADER)${NC}" -echo "" - -# ============================================================================ -# STEP 9.6: PUBLISH the verified draft (draft → upload → verify → PUBLISH) -# The release was a hidden draft until now; only an artifact proven to match the -# local ZIP gets published, closing the "published-before-verified" window. -# ============================================================================ -echo -e "${YELLOW}[9.6/9] Publishing the verified draft release...${NC}" -if [ "$IS_PRERELEASE" = true ]; then - gh release edit "v${VERSION}" --draft=false --prerelease -else - gh release edit "v${VERSION}" --draft=false --latest -fi -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ERROR: failed to publish the verified draft release${NC}" - echo -e "${RED} The release remains a DRAFT (not visible/installable). Publish manually after review:${NC}" - echo -e "${RED} gh release edit v${VERSION} --draft=false${NC}" - exit 1 -fi -echo -e "${GREEN}✓ Release published (verified as a draft first)${NC}" -echo "" - -# ---------------------------------------------------------------------------- -# Trigger the Docker image rebuild (stable releases only, non-fatal). -# Notifies fabiodalez-dev/pinakes-docker so it rebuilds + republishes the -# multi-arch image for this version. The Docker repo also has a daily poller -# as a safety net, so a failure here is never fatal to the release. -# ---------------------------------------------------------------------------- -if [ "$IS_PRERELEASE" = false ] && command -v gh >/dev/null 2>&1; then - echo -e "${YELLOW}Notifying pinakes-docker to rebuild the image…${NC}" - if gh api -X POST "repos/fabiodalez-dev/pinakes-docker/dispatches" \ - -f "event_type=pinakes_release" \ - -F "client_payload[pinakes_version]=${VERSION}" >/dev/null 2>&1; then - echo -e "${GREEN}✓ Docker image rebuild triggered for v${VERSION}${NC}" - else - echo -e "${YELLOW}⚠ Could not trigger pinakes-docker (non-fatal). Trigger manually:${NC}" - echo " gh workflow run build-publish-docker.yml -R fabiodalez-dev/pinakes-docker -f version=${VERSION}" - fi - echo "" -fi - -# NOTE: GitHub "immutable releases" (assets frozen post-publish) is a repository -# setting (Settings → General → Releases → Require immutable releases). Enable it -# there so a published asset can never be silently overwritten by a later workflow. - -# ============================================================================ -# STEP 10: Done (no dev restore needed — PHPStan is global, not in vendor) -# ============================================================================ -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}✅ RELEASE v${VERSION} CREATED SUCCESSFULLY!${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" -echo "Release URL: https://github.com/fabiodalez-dev/Pinakes/releases/tag/v${VERSION}" -echo "" -if [ "$IS_PRERELEASE" = true ]; then - echo -e "${YELLOW}This is a PRERELEASE (Release Candidate).${NC}" - echo "- It is hidden from the in-app updater by default (GitHub /releases/latest skips it)." - echo "- To install/test it, enable the RC channel on the target install:" - echo " UPDATER_ALLOW_PRERELEASE=1 (or UPDATER_CHANNEL=rc) in .env" - echo " then the updater will offer v${VERSION}. Do NOT announce it to end users." -else - echo "Next steps:" - echo "1. Edit release notes on GitHub if needed" - echo "2. Test the update from admin panel" - echo "3. Announce the release" -fi -echo "" +[[ "$(jq -r '.draft' <<<"$release_json")" == "false" ]] \ + || fail "${TAG_NAME} is still a draft" +[[ "$(jq -r '.immutable' <<<"$release_json")" == "true" ]] \ + || fail "${TAG_NAME} was published without immutable-release protection" +expected_prerelease="$IS_PRERELEASE" +[[ "$(jq -r '.prerelease' <<<"$release_json")" == "$expected_prerelease" ]] \ + || fail "${TAG_NAME} prerelease flag does not match version policy" + +expected_assets="$(printf '%s\n' \ + "RELEASE_NOTES-v${VERSION}.md" \ + "pinakes-v${VERSION}.zip" \ + "pinakes-v${VERSION}.zip.sha256" \ + "pinakes.spdx.json")" +actual_assets="$(jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort)" +[[ "$actual_assets" == "$expected_assets" ]] || { + printf 'Expected assets:\n%s\nActual assets:\n%s\n' "$expected_assets" "$actual_assets" >&2 + fail "published asset set is incomplete or unexpected" +} + +if ! jq -e ' + (.assets | length == 4) + and all(.assets[]; + .state == "uploaded" + and .size > 0 + and (.digest | test("^sha256:[0-9a-fA-F]{64}$")) + and .uploader.login == "github-actions[bot]") +' >/dev/null <<<"$release_json"; then + fail "every asset must be non-empty, uploaded by github-actions[bot], and expose a SHA-256 digest" +fi + +zip_asset_id="$(jq -r --arg name "pinakes-v${VERSION}.zip" \ + '.assets[] | select(.name == $name) | .id' <<<"$release_json")" +checksum_asset_id="$(jq -r --arg name "pinakes-v${VERSION}.zip.sha256" \ + '.assets[] | select(.name == $name) | .id' <<<"$release_json")" +zip_digest="$(jq -r --arg name "pinakes-v${VERSION}.zip" \ + '.assets[] | select(.name == $name) | .digest' <<<"$release_json")" +checksum_body="$(gh api -H 'Accept: application/octet-stream' \ + "repos/${REPOSITORY}/releases/assets/${checksum_asset_id}")" +checksum_sha="$(awk 'NR == 1 { print $1 }' <<<"$checksum_body")" +[[ "$checksum_sha" =~ ^[0-9a-fA-F]{64}$ ]] \ + || fail "checksum asset does not contain a valid SHA-256" +[[ "${zip_digest#sha256:}" == "$checksum_sha" ]] \ + || fail "ZIP API digest and published checksum disagree" +[[ -n "$zip_asset_id" ]] || fail "ZIP asset id is missing" + +tagged_commit="$(git rev-list -n 1 "$TAG_NAME")" +[[ "$tagged_commit" == "$head_sha" ]] || fail "local release tag no longer resolves to the preflighted commit" +[[ -z "$(git status --porcelain)" ]] || fail "release orchestration modified the working tree" + +release_url="$(jq -r '.html_url' <<<"$release_json")" +printf '\nRelease %s is published and verified: %s\n' "$TAG_NAME" "$release_url" +printf 'Workflow: %s\n' "$run_url" diff --git a/scripts/maintenance.php b/scripts/maintenance.php index b0e68d15c..d2b851ae4 100644 --- a/scripts/maintenance.php +++ b/scripts/maintenance.php @@ -102,6 +102,7 @@ } $db->set_charset($cfg['charset']); +\App\Support\DateHelper::synchronizeDatabaseSession($db); $reservationManager = new ReservationManager($db); @@ -282,4 +283,4 @@ echo "=== MAINTENANCE COMPLETED ===\n"; echo "Time: " . date('Y-m-d H:i:s') . "\n"; -$db->close(); \ No newline at end of file +$db->close(); diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 29eefcbb1..889d3e3c1 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -48,11 +48,13 @@ class NcipServerPlugin */ private const MAX_REQUEST_BYTES = 262_144; - /** @phpstan-ignore property.onlyWritten */ private HookManager $hookManager; private \mysqli $db; private ?int $pluginId = null; + /** Partner attivo risolto dal FromAgencyId del messaggio corrente (per il log transazioni). */ + private ?int $currentPartnerId = null; + public function __construct(\mysqli $db, HookManager $hookManager) { $this->db = $db; @@ -476,6 +478,15 @@ public function ncipAction( // Determine the message type (first child element after NCIPMessage root) $messageType = $this->detectMessageType($xml); + // La tabella partner esisteva storicamente come metadato amministrativo: + // la sua mera presenza non può trasformarsi implicitamente in una nuova + // policy di autorizzazione e bloccare i client NCIP già configurati. + // Quando FromAgencyId identifica un partner attivo lo conserviamo per il + // log transazioni; un header assente/sconosciuto lascia partner_id NULL. + // L'autorità per le operazioni di scrittura resta la Basic auth staff. + $partner = $this->resolvePartner($xml, $messageType); + $this->currentPartnerId = $partner !== null ? (int) $partner['id'] : null; + $result = match ($messageType) { 'LookupItem' => $this->handleLookupItem($request, $response, $xml), 'LookupUser' => $this->handleLookupUser($request, $response, $xml, $caller), @@ -509,8 +520,8 @@ private function handleLookupItem( \SimpleXMLElement $xml ): ResponseInterface { // Extract ItemIdentifierValue - $ns = self::NCIP_NS; - $itemIdRaw = (string) ($xml->children($ns)->LookupItem->ItemId->ItemIdentifierValue ?? ''); + $message = $this->messageNode($xml, 'LookupItem'); + $itemIdRaw = (string) ($message?->ItemId->ItemIdentifierValue ?? ''); if ($itemIdRaw === '') { return $this->xmlResponse( $response, @@ -553,8 +564,8 @@ private function handleLookupUser( ); } - $ns = self::NCIP_NS; - $userIdRaw = (string) ($xml->children($ns)->LookupUser->UserId->UserIdentifierValue ?? ''); + $message = $this->messageNode($xml, 'LookupUser'); + $userIdRaw = (string) ($message?->UserId->UserIdentifierValue ?? ''); $targetId = $userIdRaw !== '' ? $this->parseNcipNumericId($userIdRaw) : null; if ($targetId === null) { return $this->xmlResponse( @@ -600,9 +611,9 @@ private function handleCheckOutItem( ); } - $ns = self::NCIP_NS; - $itemId = $this->parseNcipNumericId((string) ($xml->children($ns)->CheckOutItem->ItemId->ItemIdentifierValue ?? '')); - $userId = $this->parseNcipNumericId((string) ($xml->children($ns)->CheckOutItem->UserId->UserIdentifierValue ?? '')); + $message = $this->messageNode($xml, 'CheckOutItem'); + $itemId = $this->parseNcipNumericId((string) ($message?->ItemId->ItemIdentifierValue ?? '')); + $userId = $this->parseNcipNumericId((string) ($message?->UserId->UserIdentifierValue ?? '')); if ($itemId === null || $userId === null) { return $this->xmlResponse( $response, @@ -650,6 +661,10 @@ private function handleCheckOutItem( } } + // Log della transazione come per RequestItem/CancelRequestItem: prima + // solo le richieste venivano registrate e il log admin era parziale. + $this->logTransaction('CheckOutItem', $loanId, null); + return $this->xmlResponse($response, $this->buildCheckOutItemResponse($itemId, $userId, $dueDate)); } @@ -669,16 +684,44 @@ private function handleCheckInItem( ); } - $ns = self::NCIP_NS; - $itemId = $this->parseNcipNumericId((string) ($xml->children($ns)->CheckInItem->ItemId->ItemIdentifierValue ?? '')); + $checkInItem = $this->messageNode($xml, 'CheckInItem'); + $itemId = $this->parseNcipNumericId((string) ($checkInItem?->ItemId->ItemIdentifierValue ?? '')); if ($itemId === null) { return $this->xmlResponse( $response, $this->buildProblem('Invalid ItemId', 'invalid-data') ); } + // UserId è opzionale, ma se il client lo include deve essere un ID + // positivo valido: trattare un valore malformato come "assente" farebbe + // ricadere sulla ricerca per solo titolo e potrebbe chiudere il prestito + // di un altro utente. + $checkInUserId = null; + if ($checkInItem !== null && isset($checkInItem->UserId)) { + $checkInUserId = $this->parseNcipNumericId((string) ($checkInItem->UserId->UserIdentifierValue ?? '')); + if ($checkInUserId === null) { + return $this->xmlResponse( + $response, + $this->buildProblem('Invalid UserId', 'invalid-data') + ); + } + } - $loan = $this->findActiveLoan($itemId); + $ambiguousLoan = false; + $loanLookupFailed = false; + $loan = $this->findActiveLoan($itemId, $checkInUserId, $ambiguousLoan, $loanLookupFailed); + if ($loanLookupFailed) { + return $this->xmlResponse( + $response, + $this->buildProblem('Failed to look up active loan', 'temporary-processing-failure') + ); + } + if ($ambiguousLoan) { + return $this->xmlResponse( + $response, + $this->buildProblem('Multiple active loans for this item; UserId is required', 'invalid-data') + ); + } if ($loan === null) { return $this->xmlResponse( $response, @@ -686,13 +729,19 @@ private function handleCheckInItem( ); } - if (!$this->closeLoan((int) $loan['id'])) { + $resolvedCheckInUserId = (int) $loan['utente_id']; + if (!$this->closeLoan((int) $loan['id'], $itemId, $resolvedCheckInUserId)) { // A concurrent CheckInItem may have returned this exact loan between // findActiveLoan() and closeLoan(): LoanRepository::close()'s state // guard then returns false. That is not a failure — the item IS // checked in — so honour the F052 idempotency contract and report // success instead of a retryable temporary-processing-failure. - if ($this->isLoanReturned((int) $loan['id'])) { + if ($this->isLoanReturned((int) $loan['id'], $itemId, $resolvedCheckInUserId)) { + // This request still completed a CheckInItem operation and + // must be auditable even though another concurrent request won + // the close race. The normal-success branch below logs once on + // its own path, so this does not double-log a single request. + $this->logTransaction('CheckInItem', (int) $loan['id'], null); return $this->xmlResponse($response, $this->buildCheckInItemResponse($itemId)); } return $this->xmlResponse( @@ -700,6 +749,7 @@ private function handleCheckInItem( $this->buildProblem('Failed to check in item', 'temporary-processing-failure') ); } + $this->logTransaction('CheckInItem', (int) $loan['id'], null); return $this->xmlResponse($response, $this->buildCheckInItemResponse($itemId)); } @@ -719,16 +769,42 @@ private function handleRenewItem( ); } - $ns = self::NCIP_NS; - $itemId = $this->parseNcipNumericId((string) ($xml->children($ns)->RenewItem->ItemId->ItemIdentifierValue ?? '')); + $renewItem = $this->messageNode($xml, 'RenewItem'); + $itemId = $this->parseNcipNumericId((string) ($renewItem?->ItemId->ItemIdentifierValue ?? '')); if ($itemId === null) { return $this->xmlResponse( $response, $this->buildProblem('Invalid ItemId', 'invalid-data') ); } + // Come CheckInItem: assenza ammessa, presenza malformata/non-positiva no. + // In particolare non degradare "abc"/"0" a una ricerca per solo titolo. + $renewUserId = null; + if ($renewItem !== null && isset($renewItem->UserId)) { + $renewUserId = $this->parseNcipNumericId((string) ($renewItem->UserId->UserIdentifierValue ?? '')); + if ($renewUserId === null) { + return $this->xmlResponse( + $response, + $this->buildProblem('Invalid UserId', 'invalid-data') + ); + } + } - $loan = $this->findActiveLoan($itemId); + $ambiguousLoan = false; + $loanLookupFailed = false; + $loan = $this->findActiveLoan($itemId, $renewUserId, $ambiguousLoan, $loanLookupFailed); + if ($loanLookupFailed) { + return $this->xmlResponse( + $response, + $this->buildProblem('Failed to look up active loan', 'temporary-processing-failure') + ); + } + if ($ambiguousLoan) { + return $this->xmlResponse( + $response, + $this->buildProblem('Multiple active loans for this item; UserId is required', 'invalid-data') + ); + } if ($loan === null) { return $this->xmlResponse( $response, @@ -737,13 +813,20 @@ private function handleRenewItem( } $failureReason = 'db_error'; - $newDue = $this->extendLoan((int) $loan['id'], $failureReason); + $resolvedRenewUserId = (int) $loan['utente_id']; + $newDue = $this->extendLoan( + (int) $loan['id'], + $itemId, + $resolvedRenewUserId, + $failureReason + ); if ($newDue === null) { // Map permanent rejections to stable NCIP ProblemTypes so the partner // stops retrying a renewal that can never succeed. Only a genuine DB // error stays retryable (temporary-processing-failure). $problemType = match ($failureReason) { 'not_found' => 'unknown-item', + 'identity_changed' => 'item-not-checked-out', 'ineligible_state' => 'item-not-renewable', 'user_ineligible' => 'user-ineligible-to-renew', 'max_renewals' => 'maximum-renewals-exceeded', @@ -757,7 +840,9 @@ private function handleRenewItem( ); } - return $this->xmlResponse($response, $this->buildRenewItemResponse($itemId, $newDue, (int) ($loan['utente_id'] ?? 0))); + $this->logTransaction('RenewItem', (int) $loan['id'], null); + + return $this->xmlResponse($response, $this->buildRenewItemResponse($itemId, $newDue, $resolvedRenewUserId)); } /** @@ -776,9 +861,9 @@ private function handleRequestItem( ); } - $ns = self::NCIP_NS; - $itemId = $this->parseNcipNumericId((string) ($xml->children($ns)->RequestItem->ItemId->ItemIdentifierValue ?? '')); - $userId = $this->parseNcipNumericId((string) ($xml->children($ns)->RequestItem->UserId->UserIdentifierValue ?? '')); + $message = $this->messageNode($xml, 'RequestItem'); + $itemId = $this->parseNcipNumericId((string) ($message?->ItemId->ItemIdentifierValue ?? '')); + $userId = $this->parseNcipNumericId((string) ($message?->UserId->UserIdentifierValue ?? '')); if ($itemId === null || $userId === null) { return $this->xmlResponse( $response, @@ -795,7 +880,7 @@ private function handleRequestItem( ); } - $requestId = (string) ($xml->children($ns)->RequestItem->RequestId->RequestIdentifierValue ?? ''); + $requestId = (string) ($message?->RequestId->RequestIdentifierValue ?? ''); $today = \App\Support\DateHelper::today(); $loanDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'loan_duration_days', '30') ?? 30); $loanDays = $loanDays > 0 ? $loanDays : 30; @@ -844,9 +929,8 @@ private function handleCancelRequestItem( ); } - $ns = self::NCIP_NS; - $itemId = $this->parseNcipNumericId((string) ($xml->children($ns)->CancelRequestItem->ItemId->ItemIdentifierValue ?? '')); - $userId = $this->parseNcipNumericId((string) ($xml->children($ns)->CancelRequestItem->UserId->UserIdentifierValue ?? '')); + $message = $this->messageNode($xml, 'CancelRequestItem'); + $itemId = $this->parseNcipNumericId((string) ($message?->ItemId->ItemIdentifierValue ?? '')); if ($itemId === null) { return $this->xmlResponse( @@ -855,26 +939,59 @@ private function handleCancelRequestItem( ); } - $loan = $this->findNcipLoan($itemId, $userId); - if ($loan === null) { - return $this->xmlResponse( - $response, - $this->buildProblem('No active ILL request for this item', 'item-not-checked-out') - ); + // Come CheckInItem/RenewItem: UserId assente è ammesso, ma un valore + // presente e malformato NON deve degradare a null — la ricerca per solo + // titolo con LIMIT 1 potrebbe annullare la richiesta di un altro utente. + $userId = null; + if ($message !== null && isset($message->UserId)) { + $userId = $this->parseNcipNumericId((string) ($message->UserId->UserIdentifierValue ?? '')); + if ($userId === null) { + return $this->xmlResponse( + $response, + $this->buildProblem('Invalid UserId', 'invalid-data') + ); + } } try { - $this->cancelLoan((int) $loan['id']); + $cancelResult = $this->cancelPendingNcipRequest($itemId, $userId); } catch (\RuntimeException $e) { - SecureLogger::error('[NcipServer] cancelLoan failed: ' . $e->getMessage()); + SecureLogger::error('[NcipServer] cancelPendingNcipRequest failed: ' . $e->getMessage()); return $this->xmlResponse( $response, $this->buildProblem('Failed to cancel request', 'temporary-processing-failure') ); } - $this->logTransaction('CancelRequestItem', (int) $loan['id'], null); + if ($cancelResult['status'] === 'ambiguous') { + return $this->xmlResponse( + $response, + $this->buildProblem( + $userId === null + ? 'Multiple pending ILL requests for this item; UserId is required' + : 'Multiple matching pending ILL requests for this item and user', + 'invalid-data' + ) + ); + } + if ($cancelResult['status'] !== 'cancelled') { + // Approval and cancellation serialize on the book row. If approval + // won, the locked query no longer sees a pending request and this + // branch cannot overwrite the approved loan or orphan its copy. + return $this->xmlResponse( + $response, + $this->buildProblem('No active ILL request for this item', 'item-not-checked-out') + ); + } + + $this->logTransaction('CancelRequestItem', $cancelResult['loan_id'], null); - return $this->xmlResponse($response, $this->buildCancelRequestItemResponse($itemId, $userId)); + // Like handleRenewItem, answer with the borrower the locked row + // resolved: when the client omitted UserId the request-derived value + // is null even though the cancellation just determined the user. + return $this->xmlResponse( + $response, + $this->buildCancelRequestItemResponse($itemId, $cancelResult['user_id']) + ); } // ─── XML builders ───────────────────────────────────────────────────────── @@ -1275,28 +1392,68 @@ private function fetchUser(int $id): ?array } /** + * @param-out bool $ambiguous True only when UserId is absent and the title + * has more than one open NCIP loan. + * @param-out bool $databaseError True when the lookup could not be completed. * @return array|null */ - private function findActiveLoan(int $bookId): ?array + private function findActiveLoan( + int $bookId, + ?int $userId, + bool &$ambiguous, + bool &$databaseError + ): ?array { - $stmt = $this->db->prepare( - "SELECT id, libro_id, utente_id, data_scadenza - FROM prestiti - WHERE libro_id = ? AND origine = 'ncip' AND attivo = 1 - AND stato IN ('in_corso','in_ritardo') - ORDER BY data_prestito DESC LIMIT 1" - ); - if ($stmt === false) { return null; } - $stmt->bind_param('i', $bookId); - $stmt->execute(); - $res = $stmt->get_result(); - if (!($res instanceof \mysqli_result)) { + $ambiguous = false; + $databaseError = false; + // Con più prestiti NCIP aperti dello stesso titolo (utenti diversi su + // copie diverse) il solo libro_id è ambiguo. Leggine al massimo due: + // senza UserId due righe devono produrre un errore, mai una mutazione + // arbitraria; con UserId basta la singola riga filtrata. + $userFilter = $userId !== null ? ' AND utente_id = ?' : ''; + $limit = $userId !== null ? 1 : 2; + $stmt = null; + try { + $stmt = $this->db->prepare( + "SELECT id, libro_id, utente_id, data_scadenza + FROM prestiti + WHERE libro_id = ? AND origine = 'ncip' AND attivo = 1 + AND stato IN ('in_corso','in_ritardo'){$userFilter} + ORDER BY data_prestito DESC, id DESC LIMIT {$limit}" + ); + if ($stmt === false) { + throw new \RuntimeException('prepare failed: ' . $this->db->error); + } + if ($userId !== null) { + $stmt->bind_param('ii', $bookId, $userId); + } else { + $stmt->bind_param('i', $bookId); + } + $stmt->execute(); + $res = $stmt->get_result(); + if (!($res instanceof \mysqli_result)) { + throw new \RuntimeException('result retrieval failed: ' . $stmt->error); + } + $row = $res->fetch_assoc(); + if ($userId === null && $row !== null && $res->fetch_assoc() !== null) { + $ambiguous = true; + $stmt->close(); + return null; + } $stmt->close(); + return is_array($row) ? $row : null; + } catch (\Throwable $e) { + if ($stmt instanceof \mysqli_stmt) { + try { + $stmt->close(); + } catch (\Throwable) { + // Preserve the original lookup failure below. + } + } + $databaseError = true; + SecureLogger::error('[NcipServer] findActiveLoan failed: ' . $e->getMessage()); return null; } - $row = $res->fetch_assoc(); - $stmt->close(); - return is_array($row) ? $row : null; } /** @@ -1412,14 +1569,16 @@ private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND p.data_prestito <= ? - AND (p.stato = 'in_ritardo' OR p.data_scadenza >= ?) + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) AND ((p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL)) ) ORDER BY c.numero_inventario ASC LIMIT 1 FOR UPDATE" ); - $copy->bind_param('iss', $bookId, $dueDate, $today); + $copy->bind_param('isss', $bookId, $dueDate, $today, $today); $copy->execute(); $copyRow = $copy->get_result()->fetch_assoc(); $copy->close(); @@ -1593,80 +1752,203 @@ private function createLoanNcip( } /** - * @return array|null + * Resolve and cancel one still-pending NCIP request atomically. + * + * The book lock is acquired before the pending-request lookup, matching the + * lock order used by RequestItem and approval. Consequently a concurrent + * insert/approval either commits before this SELECT ... FOR UPDATE (and is + * visible here) or waits until cancellation commits. Ambiguity detection is + * therefore based on the same locked state that the UPDATE mutates. + * + * @return array{status:'cancelled', loan_id:int, user_id:int}|array{status:'not_found'|'ambiguous'} */ - private function findNcipLoan(int $bookId, ?int $userId): ?array + private function cancelPendingNcipRequest(int $bookId, ?int $userId): array { - // CancelRequestItem cancels the outstanding NCIP request, not an item - // already approved/checked out (those need the normal check-in/cancel - // lifecycle so their copy and queues are released correctly). - $sql = "SELECT id, libro_id, utente_id FROM prestiti - WHERE libro_id = ? AND origine = 'ncip' AND attivo = 0 AND stato = 'pendente'"; - $types = 'i'; - $params = [$bookId]; - if ($userId !== null) { - $sql .= ' AND utente_id = ?'; - $types .= 'i'; - $params[] = $userId; - } - $sql .= ' ORDER BY created_at DESC LIMIT 1'; + $inTransaction = false; + try { + $this->db->begin_transaction(); + $inTransaction = true; + + // CI-SOFT-DELETE-EXEMPT: cancelling a pending request must remain + // possible after a title is archived, just as returning its copy is. + $book = $this->db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); + if ($book === false) { + throw new \RuntimeException('book lock prepare failed: ' . $this->db->error); + } + $book->bind_param('i', $bookId); + $book->execute(); + $bookExists = (bool) $book->get_result()->fetch_row(); + $book->close(); + if (!$bookExists) { + $this->db->rollback(); + $inTransaction = false; + return ['status' => 'not_found']; + } - $stmt = $this->db->prepare($sql); - if ($stmt === false) { return null; } - $stmt->bind_param($types, ...$params); - $stmt->execute(); - $res = $stmt->get_result(); - if (!($res instanceof \mysqli_result)) { - $stmt->close(); - return null; + $userFilter = $userId !== null ? ' AND utente_id = ?' : ''; + $loanStmt = $this->db->prepare( + "SELECT id, libro_id, utente_id, origine, attivo, stato + FROM prestiti + WHERE libro_id = ? AND origine = 'ncip' + AND attivo = 0 AND stato = 'pendente' AND copia_id IS NULL{$userFilter} + ORDER BY created_at DESC, id DESC + LIMIT 2 FOR UPDATE" + ); + if ($loanStmt === false) { + throw new \RuntimeException('loan lock prepare failed: ' . $this->db->error); + } + if ($userId !== null) { + $loanStmt->bind_param('ii', $bookId, $userId); + } else { + $loanStmt->bind_param('i', $bookId); + } + $loanStmt->execute(); + $result = $loanStmt->get_result(); + $lockedLoans = $result instanceof \mysqli_result + ? $result->fetch_all(MYSQLI_ASSOC) + : []; + $loanStmt->close(); + + if ($lockedLoans === []) { + $this->db->rollback(); + $inTransaction = false; + return ['status' => 'not_found']; + } + if (count($lockedLoans) > 1) { + $this->db->rollback(); + $inTransaction = false; + return ['status' => 'ambiguous']; + } + $lockedLoan = $lockedLoans[0]; + $loanId = (int) $lockedLoan['id']; + $resolvedUserId = (int) $lockedLoan['utente_id']; + + // Repeat every lifecycle/identity predicate in the write itself. + // The row lock makes a lost race impossible, while the guarded + // UPDATE also protects this invariant from future refactors. + $update = $this->db->prepare( + "UPDATE prestiti + SET stato = 'annullato', attivo = 0, updated_at = NOW() + WHERE id = ? AND libro_id = ? AND utente_id = ? + AND origine = 'ncip' AND attivo = 0 AND stato = 'pendente' + AND copia_id IS NULL" + ); + if ($update === false) { + throw new \RuntimeException('cancel prepare failed: ' . $this->db->error); + } + $update->bind_param('iii', $loanId, $bookId, $resolvedUserId); + $update->execute(); + $affected = $update->affected_rows; + $update->close(); + if ($affected !== 1) { + $this->db->rollback(); + $inTransaction = false; + return ['status' => 'not_found']; + } + + $this->db->commit(); + $inTransaction = false; + return [ + 'status' => 'cancelled', + 'loan_id' => $loanId, + 'user_id' => $resolvedUserId, + ]; + } catch (\Throwable $e) { + if ($inTransaction) { + try { + $this->db->rollback(); + } catch (\Throwable) { + // Preserve the original database failure. + } + } + throw new \RuntimeException( + '[NcipServer] ' . __FUNCTION__ . ' failed: ' . $e->getMessage(), + 0, + $e + ); } - $row = $res->fetch_assoc(); - $stmt->close(); - return is_array($row) ? $row : null; } - private function cancelLoan(int $loanId): void + private function logTransaction(string $messageType, int $prestitoId, ?string $requestId): void { $stmt = $this->db->prepare( - "UPDATE prestiti SET stato = 'annullato', attivo = 0, updated_at = NOW() WHERE id = ?" + "INSERT INTO ncip_transactions (partner_id, message_type, prestito_id, request_id, status, created_at) + VALUES (?, ?, ?, ?, 'success', NOW())" ); - if ($stmt === false) { - throw new \RuntimeException('[NcipServer] ' . __FUNCTION__ . ' prepare failed: ' . $this->db->error); - } - $stmt->bind_param('i', $loanId); - if (!$stmt->execute()) { - $err = $stmt->error; - $stmt->close(); - throw new \RuntimeException('[NcipServer] ' . __FUNCTION__ . ' execute failed: ' . $err); - } + if ($stmt === false) { return; } + $stmt->bind_param('isis', $this->currentPartnerId, $messageType, $prestitoId, $requestId); + $stmt->execute(); $stmt->close(); } - private function logTransaction(string $messageType, int $prestitoId, ?string $requestId): void + /** + * Risolve il partner ATTIVO dichiarato nel FromAgencyId dell'InitiationHeader + * del messaggio corrente, per agency_id, code o ISIL. Null se il messaggio + * non dichiara un'agenzia o nessun partner attivo corrisponde. + * + * @return array|null + */ + private function resolvePartner(\SimpleXMLElement $xml, string $messageType): ?array { + if ($messageType === '') { + return null; + } + $message = $this->messageNode($xml, $messageType); + if ($message === null) { + return null; + } + $agency = trim((string) ($message->InitiationHeader->FromAgencyId->AgencyId ?? '')); + if ($agency === '' || strlen($agency) > 255) { + return null; + } + $stmt = $this->db->prepare( - "INSERT INTO ncip_transactions (message_type, prestito_id, request_id, status, created_at) - VALUES (?, ?, ?, 'success', NOW())" + 'SELECT id, name, agency_id, code, isil + FROM ncip_partners + WHERE active = 1 AND (agency_id = ? OR code = ? OR isil = ?) + LIMIT 2' ); - if ($stmt === false) { return; } - $stmt->bind_param('sis', $messageType, $prestitoId, $requestId); + if ($stmt === false) { return null; } + $stmt->bind_param('sss', $agency, $agency, $agency); $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); + if (count($rows) > 1) { + // Lo stesso identificativo corrisponde a più partner attivi: la + // risoluzione e il partner_id loggato sarebbero arbitrari. I partner + // sono metadata opzionali (l'autorità resta la Basic auth staff), + // quindi registra NULL e lascia l'ambiguità da sanare in + // configurazione senza attribuire l'operazione al partner sbagliato. + SecureLogger::warning('[NcipServer] Ambiguous FromAgencyId: multiple active partners match the same identifier; treating as unresolved'); + return null; + } + return $rows[0] ?? null; } - private function closeLoan(int $loanId): bool + private function closeLoan(int $loanId, int $expectedBookId, int $expectedUserId): bool { try { - $closed = (new \App\Models\LoanRepository($this->db))->close($loanId); - if (!$closed) { - return false; - } - (new \App\Support\NotificationService($this->db))->sendLoanReturnedNotification($loanId); - return true; + $closed = (new \App\Models\LoanRepository($this->db))->close( + $loanId, + $expectedBookId, + $expectedUserId + ); } catch (\Throwable $e) { SecureLogger::error('[NcipServer] closeLoan failed: ' . $e->getMessage()); return false; } + if (!$closed) { + return false; + } + try { + (new \App\Support\NotificationService($this->db))->sendLoanReturnedNotification($loanId); + } catch (\Throwable $e) { + // Il prestito È chiuso: un errore di notifica non deve trasformare + // un check-in riuscito in un false (che farebbe imboccare al + // chiamante il ramo idempotente saltando logTransaction()). + SecureLogger::error('[NcipServer] return notification failed: ' . $e->getMessage()); + } + return true; } /** @@ -1674,13 +1956,16 @@ private function closeLoan(int $loanId): bool * CheckInItem idempotent: a concurrent/replayed check-in whose loan is * already 'restituito' is a success, not a temporary-processing-failure. */ - private function isLoanReturned(int $loanId): bool + private function isLoanReturned(int $loanId, int $expectedBookId, int $expectedUserId): bool { - $stmt = $this->db->prepare('SELECT attivo, stato FROM prestiti WHERE id = ?'); + $stmt = $this->db->prepare( + 'SELECT attivo, stato FROM prestiti + WHERE id = ? AND libro_id = ? AND utente_id = ?' + ); if ($stmt === false) { return false; } - $stmt->bind_param('i', $loanId); + $stmt->bind_param('iii', $loanId, $expectedBookId, $expectedUserId); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); $stmt->close(); @@ -1702,27 +1987,23 @@ private function isLoanReturned(int $loanId): bool * * @param-out string $failureReason */ - private function extendLoan(int $loanId, ?string &$failureReason = null): ?string + private function extendLoan( + int $loanId, + int $expectedBookId, + int $expectedUserId, + ?string &$failureReason = null + ): ?string { $failureReason = 'db_error'; - $lookup = $this->db->prepare('SELECT libro_id FROM prestiti WHERE id = ?'); - if ($lookup === false) { + if ($expectedBookId <= 0 || $expectedUserId <= 0) { + $failureReason = 'identity_changed'; return null; } - $lookup->bind_param('i', $loanId); - $lookup->execute(); - $row = $lookup->get_result()->fetch_assoc(); - $lookup->close(); - if (!$row) { - $failureReason = 'not_found'; - return null; - } - $bookId = (int) $row['libro_id']; $this->db->begin_transaction(); try { $book = $this->db->prepare('SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE'); - $book->bind_param('i', $bookId); + $book->bind_param('i', $expectedBookId); $book->execute(); $bookExists = (bool) $book->get_result()->fetch_row(); $book->close(); @@ -1733,26 +2014,32 @@ private function extendLoan(int $loanId, ?string &$failureReason = null): ?strin } $loanStmt = $this->db->prepare( - "SELECT libro_id, utente_id, copia_id, data_scadenza, stato, attivo, renewals + "SELECT libro_id, utente_id, copia_id, data_scadenza, stato, attivo, renewals, origine FROM prestiti WHERE id = ? FOR UPDATE" ); $loanStmt->bind_param('i', $loanId); $loanStmt->execute(); $loan = $loanStmt->get_result()->fetch_assoc(); $loanStmt->close(); - if (!$loan || (int) $loan['libro_id'] !== $bookId - || (int) $loan['attivo'] !== 1 || $loan['stato'] !== 'in_corso') { + if (!$loan + || (int) $loan['libro_id'] !== $expectedBookId + || (int) $loan['utente_id'] !== $expectedUserId + || $loan['origine'] !== 'ncip') { + $this->db->rollback(); + $failureReason = 'identity_changed'; + return null; + } + if ((int) $loan['attivo'] !== 1 || $loan['stato'] !== 'in_corso') { $this->db->rollback(); $failureReason = 'ineligible_state'; return null; } - $userId = (int) $loan['utente_id']; $userLock = $this->db->prepare('SELECT id FROM utenti WHERE id = ? FOR UPDATE'); - $userLock->bind_param('i', $userId); + $userLock->bind_param('i', $expectedUserId); $userLock->execute(); $userLock->close(); - if (\App\Support\LoanEligibility::checkUser($this->db, $userId) !== null) { + if (\App\Support\LoanEligibility::checkUser($this->db, $expectedUserId) !== null) { $this->db->rollback(); $failureReason = 'user_ineligible'; return null; @@ -1771,8 +2058,15 @@ private function extendLoan(int $loanId, ?string &$failureReason = null): ?strin $currentDue = (string) $loan['data_scadenza']; $newDueDate = (new \DateTimeImmutable($currentDue))->modify("+{$renewDays} days")->format('Y-m-d'); + // #336 parity con PrestitiController::renew/bulkExtend: il giorno di + // scadenza corrente è già detenuto da QUESTO prestito, quindi la + // finestra rivendicata parte dal giorno successivo. Con la finestra + // [currentDue, newDue] un RenewItem falliva dove il rinnovo web + // identico riusciva (giorno di confine contato due volte). + $extensionStart = (new \DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $capacity = new \App\Services\CapacityService($this->db); - if (!$capacity->hasFreeCapacity($bookId, $currentDue, $newDueDate, excludePrestitoId: $loanId)) { + if (!$capacity->hasFreeCapacity($expectedBookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { $this->db->rollback(); $failureReason = 'no_capacity'; return null; @@ -1780,15 +2074,19 @@ private function extendLoan(int $loanId, ?string &$failureReason = null): ?strin $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { + $applicationToday = \App\Support\DateHelper::today(); $overlap = $this->db->prepare( "SELECT 1 FROM prestiti WHERE copia_id = ? AND id <> ? - AND data_prestito <= ? AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND data_prestito <= ? + AND (stato = 'in_ritardo' + OR (stato = 'in_corso' AND data_scadenza < ?) + OR data_scadenza >= ?) AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL)) LIMIT 1" ); - $overlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $currentDue); + $overlap->bind_param('iisss', $copyId, $loanId, $newDueDate, $applicationToday, $extensionStart); $overlap->execute(); $hasOverlap = (bool) $overlap->get_result()->fetch_row(); $overlap->close(); @@ -1887,7 +2185,8 @@ private function authenticate(ServerRequestInterface $request): ?array private function parseNcipNumericId(string $value): ?int { $trimmed = trim($value); - return ctype_digit($trimmed) ? (int) $trimmed : null; + $id = ctype_digit($trimmed) ? (int) $trimmed : 0; + return $id > 0 ? $id : null; } /** @@ -1916,6 +2215,27 @@ private function detectMessageType(\SimpleXMLElement $xml): string return 'Unknown'; } + /** + * Resolve the request element for both standards-compliant NCIP payloads + * and legacy payloads that omit the default NCIP namespace. Keeping this + * lookup centralized prevents partner attribution and field extraction + * from disagreeing about which message was received. + */ + private function messageNode(\SimpleXMLElement $xml, string $messageType): ?\SimpleXMLElement + { + if ($messageType === '' || $messageType === 'Unknown') { + return null; + } + + $message = $xml->children(self::NCIP_NS)->{$messageType} ?? null; + if ($message instanceof \SimpleXMLElement && $message->count() > 0) { + return $message; + } + + $message = $xml->children()->{$messageType} ?? null; + return $message instanceof \SimpleXMLElement ? $message : null; + } + private function newXmlWriter(): \XMLWriter { $xw = new \XMLWriter(); diff --git a/storage/plugins/ncip-server/plugin.json b/storage/plugins/ncip-server/plugin.json index fa64c6e57..59456475b 100644 --- a/storage/plugins/ncip-server/plugin.json +++ b/storage/plugins/ncip-server/plugin.json @@ -2,13 +2,12 @@ "name": "ncip-server", "display_name": "NCIP 2.0 Server", "description": "Protocollo NISO Circulation Interchange Protocol (NCIP) 2.0 per lo scambio di informazioni sui prestiti con ILS, self-service kiosk e reti bibliotecarie. Endpoint /ncip con LookupItem, LookupUser, CheckOutItem, CheckInItem, RenewItem, RequestItem e CancelRequestItem.", - "version": "1.0.1", + "version": "1.0.2", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://www.niso.org/standards-committees/ncip", "requires_php": "8.1", "requires_app": "0.7.3", - "max_app_version": "0.7.9", "path": "ncip-server", "main_file": "wrapper.php", "metadata": { diff --git a/tests/audit-fixes-p2-p4.unit.php b/tests/audit-fixes-p2-p4.unit.php index 1c832880c..11917b37a 100644 --- a/tests/audit-fixes-p2-p4.unit.php +++ b/tests/audit-fixes-p2-p4.unit.php @@ -69,6 +69,11 @@ ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) : new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (\Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); @@ -93,6 +98,20 @@ function check(bool $cond, string $desc): void $today = DateHelper::today(); $d = static fn (int $offsetDays): string => date('Y-m-d', strtotime($today . ($offsetDays >= 0 ? " +{$offsetDays} days" : ' ' . $offsetDays . ' days'))); +$withDatabaseDate = static function (string $date, callable $callback) use ($db): mixed { + $safeDate = $db->real_escape_string($date); + $db->query("SET timestamp = UNIX_TIMESTAMP('{$safeDate} 12:00:00')"); + // The circulation triggers read the connection-bound application date + // before falling back to CURRENT_DATE(), so warping the clock must warp + // the bound date too — and restore the real application day afterwards. + $db->query("SET @pinakes_application_date = '{$safeDate}'"); + try { + return $callback(); + } finally { + $db->query('SET timestamp = 0'); + \App\Support\DateHelper::synchronizeDatabaseSession($db); + } +}; $settingsRow = static function (string $key, string $default) use ($db): string { $stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = ?"); @@ -225,12 +244,19 @@ function check(bool $cond, string $desc): void $borrower1 = $mkUser(); $picker1 = $mkUser(); [$bookP1, [$copyP1]] = $mkBook(1); - // Predecessor still out and overdue (not yet flipped), copy 'prestato'. - $prevLoan = $mkLoan($bookP1, $copyP1, $borrower1, 'in_corso', $d(-30), $d(-5)); + // Build the #366-adjacent state in its real chronological order. At the + // predecessor's due date the two windows are disjoint and both inserts are + // valid; after the clock returns to today, the still-unreturned predecessor + // has become an open-ended hold beside its already-announced successor. + [$prevLoan, $nextLoan] = $withDatabaseDate( + $d(-5), + static function () use ($mkLoan, $bookP1, $copyP1, $borrower1, $picker1, $d): array { + $previous = $mkLoan($bookP1, $copyP1, $borrower1, 'in_corso', $d(-30), $d(-5)); + $next = $mkLoan($bookP1, $copyP1, $picker1, 'da_ritirare', $d(-1), $d(10), $d(2)); + return [$previous, $next]; + } + ); $setCopyState($copyP1, 'prestato'); - // Successor already announced ready on the SAME copy (the #366-adjacent - // inconsistent state this guard must fail closed on). - $nextLoan = $mkLoan($bookP1, $copyP1, $picker1, 'da_ritirare', $d(-1), $d(10), $d(2)); $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $picker1]; $request = (new ServerRequestFactory()) @@ -360,6 +386,7 @@ function check(bool $cond, string $desc): void $request = (new ServerRequestFactory()) ->createServerRequest('POST', '/admin/loans/store') ->withParsedBody([ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), 'utente_id' => (string) $storeUser, 'libro_id' => (string) $bookS, 'data_prestito' => $d(0), diff --git a/tests/book-field-types-static.spec.js b/tests/book-field-types-static.spec.js index 00465a040..af803e5f5 100644 --- a/tests/book-field-types-static.spec.js +++ b/tests/book-field-types-static.spec.js @@ -143,13 +143,13 @@ test.describe('book field-type consistency', () => { expect(messages['Restituito — copia in restauro']).toBe('Restituito — copia in restauro'); }); - test('15) loan edge-case suite labels match the expanded 64-test coverage', () => { + test('15) loan edge-case suite labels match the expanded 66-test coverage', () => { const loanEdges = read('tests/loan-edge-cases.unit.php'); - expect(loanEdges).toContain('Exit: 0 only if all 64 pass'); + expect(loanEdges).toContain('Exit: 0 only if all 66 pass'); expect(loanEdges).toContain('* 41-48 Mixed / availability math'); expect(loanEdges).toContain('* 49-52 Misc invariants'); - expect(loanEdges).toContain('* 53-64 Canonical capacity, schedules, integrity, calendars and reservation queues'); - expect(loanEdges).toContain('printf("[%02d/64] PASS: %s\\n", $TESTNO, $desc);'); + expect(loanEdges).toContain('* 53-66 Canonical capacity, schedules, integrity, calendars and reservation queues'); + expect(loanEdges).toContain('printf("[%02d/66] PASS: %s\\n", $TESTNO, $desc);'); }); }); diff --git a/tests/code-quality.spec.js b/tests/code-quality.spec.js index a5ba48cf8..92803baf3 100644 --- a/tests/code-quality.spec.js +++ b/tests/code-quality.spec.js @@ -112,18 +112,33 @@ function grepFiles(relDir, pattern, ext = '.php', excludeSegments = []) { return hits; } -/** PHP-compatible version_compare for numeric version strings like "0.7.4". */ +/** PHP-compatible version_compare for Pinakes stable/alpha/beta/RC versions. */ function versionLte(a, b) { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - const len = Math.max(pa.length, pb.length); - for (let i = 0; i < len; i++) { - const na = pa[i] ?? 0; - const nb = pb[i] ?? 0; + const parse = value => { + const match = /^(\d+(?:\.\d+)*)(?:-(alpha|beta|rc)\.(\d+))?$/.exec(value); + if (!match) throw new Error(`Unsupported Pinakes version: ${value}`); + return { + core: match[1].split('.').map(Number), + channel: match[2] ?? null, + sequence: match[3] === undefined ? 0 : Number(match[3]), + }; + }; + const pa = parse(a); + const pb = parse(b); + const coreLength = Math.max(pa.core.length, pb.core.length); + for (let i = 0; i < coreLength; i++) { + const na = pa.core[i] ?? 0; + const nb = pb.core[i] ?? 0; if (na < nb) return true; if (na > nb) return false; } - return true; // equal → also ≤ + if (pa.channel === null || pb.channel === null) { + return pa.channel !== null || pb.channel === null; // prerelease < stable; equal stable <= stable + } + const rank = { alpha: 0, beta: 1, rc: 2 }; + if (rank[pa.channel] < rank[pb.channel]) return true; + if (rank[pa.channel] > rank[pb.channel]) return false; + return pa.sequence <= pb.sequence; } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -135,7 +150,14 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { test('1. All migrate_*.sql have version ≤ version.json (silent-skip guard)', () => { const target = JSON.parse(fs.readFileSync(path.join(ROOT, 'version.json'), 'utf-8')).version; const migrDir = path.join(ROOT, 'installer', 'database', 'migrations'); - const files = fs.readdirSync(migrDir).filter(f => /^migrate_[\d.]+\.sql$/.test(f)); + const files = fs.readdirSync(migrDir) + .filter(f => /^migrate_\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?\.sql$/.test(f)); + + expect(versionLte('0.7.64-rc.1', '0.7.64')).toBe(true); + expect(versionLte('0.7.64', '0.7.64-rc.1')).toBe(false); + expect(versionLte('0.7.65', '0.7.64-rc.1')).toBe(false); + expect(versionLte('0.7.64-alpha.2', '0.7.64-beta.1')).toBe(true); + expect(versionLte('0.7.64-beta.2', '0.7.64-rc.1')).toBe(true); const violations = files.filter(f => { const v = f.replace('migrate_', '').replace('.sql', ''); @@ -195,7 +217,7 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { const dynamicConsumers = [ ['.github/workflows/ci-upgrade-smoke.yml', 'tables'], - ['scripts/create-release.sh', 'plugins'], + ['.github/workflows/ci-upgrade-smoke.yml', 'plugins'], ['bin/build-release.sh', 'plugins'], ]; for (const [file, kind] of dynamicConsumers) { @@ -204,9 +226,22 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { .toContain(`list-source-expectations.php ${kind}`); } const releaseScript = fs.readFileSync(path.join(ROOT, 'scripts', 'create-release.sh'), 'utf-8'); - expect(releaseScript).toContain('EXPECTED_PLUGIN_COUNT=${#BUNDLED_PLUGINS[@]}'); - expect(releaseScript, 'remote release verification must not use a numeric plugin floor') - .not.toMatch(/REMOTE_PLUGIN_COUNT[^\n]*-ge\s+\d+/); + expect(releaseScript, 'release entry point must delegate source validation to the CI policy') + .toContain('bash scripts/ci-verify-release-source.sh'); + expect(releaseScript, 'release entry point must wait for the canonical workflow') + .toContain('gh run watch'); + expect(releaseScript, 'release entry point must require immutable GitHub Releases before tagging') + .toContain('immutable-releases'); + expect(releaseScript, 'release entry point must ignore stale workflow runs from a prior tag attempt') + .toContain('.databaseId > $floor'); + expect(releaseScript, 'release entry point must verify the published release is immutable') + .toContain("jq -r '.immutable'"); + expect(releaseScript, 'gh api cannot combine its mutually exclusive --slurp and --jq flags') + .not.toMatch(/--slurp\s+\\?\s*--jq/); + expect(releaseScript, 'release entry point must not build a second release archive') + .not.toMatch(/git\s+archive|bin\/build-release\.sh/); + expect(releaseScript, 'release entry point must not create, upload, or edit GitHub releases') + .not.toMatch(/gh\s+release\s+(create|upload|edit)/); expect(fs.readFileSync(path.join(ROOT, 'bin', 'build-release.sh'), 'utf-8')) .toContain('BundledPlugins::LIST declares ${#bundled_plugins[@]}'); const releaseFilter = fs.readFileSync(path.join(ROOT, '.rsync-filter'), 'utf-8'); diff --git a/tests/content-security-policy.unit.php b/tests/content-security-policy.unit.php index b357c84c8..eee3e55b8 100644 --- a/tests/content-security-policy.unit.php +++ b/tests/content-security-policy.unit.php @@ -18,7 +18,26 @@ . ''; $rewritten = ContentSecurityPolicy::addNonceAttributes($html, $nonce); -$check((bool) preg_match('/^[a-f0-9]{32}$/', $nonce), 'nonce is cryptographically sized and CSP-safe'); +$check((bool) preg_match('/^(?:[a-f0-9]{8}-){3}[a-f0-9]{8}$/D', $nonce), 'nonce is cryptographically sized and CSP-safe'); +$check((bool) preg_match('/^[a-f0-9]{32}$/D', str_replace('-', '', $nonce)), 'nonce preserves 128 bits of hexadecimal payload'); +// Same contract as the CSRF token: the nonce is stamped on every inline +// script/style, so it must never contain a 13+ digit run ZAP could mistake +// for a payment-card number. The 8-char hex groups cap runs at eight. +$piiSafe = true; +for ($i = 0; $i < 64; $i++) { + if (preg_match('/[0-9]{9,}/', ContentSecurityPolicy::createNonce())) { + $piiSafe = false; + break; + } +} +$check($piiSafe, 'generated nonces cannot contain a PII-like decimal run'); +$trailingNewlineRejected = false; +try { + ContentSecurityPolicy::header($nonce . "\n"); +} catch (InvalidArgumentException) { + $trailingNewlineRejected = true; +} +$check($trailingNewlineRejected, 'nonce validation rejects trailing control characters'); $check(str_contains($header, "script-src 'self' 'nonce-{$nonce}'"), 'script elements require the response nonce'); $check(str_contains($header, "style-src 'self' 'nonce-{$nonce}'"), 'style elements require the response nonce'); $check(!str_contains($header, "script-src 'self' 'unsafe-inline'"), 'script-src does not permit arbitrary inline scripts'); diff --git a/tests/csrf-token.unit.php b/tests/csrf-token.unit.php new file mode 100644 index 000000000..c0934fb37 --- /dev/null +++ b/tests/csrf-token.unit.php @@ -0,0 +1,43 @@ + + preg_match('/^(?:[a-f0-9]{8}-){7}[a-f0-9]{8}$/D', $token) === 1; + +$_SESSION = []; +$issued = Csrf::ensureToken(); +$check($isGroupedToken($issued), 'new tokens use eight hexadecimal groups'); +$check(strlen(str_replace('-', '', $issued)) === 64, 'grouping preserves the full 256-bit payload'); +$check(preg_match('/\d{13,}/', $issued) === 0, 'new tokens cannot contain a PII-like decimal run'); +$check(Csrf::ensureToken() === $issued, 'ensureToken keeps the current non-expired token'); +$check(Csrf::validate($issued), 'the issued token validates'); +$check(Csrf::validateWithReason($issued) === ['valid' => true, 'reason' => 'valid'], 'detailed validation accepts the issued token'); +$check(!Csrf::validate($issued . 'x'), 'a modified token is rejected'); + +$legacy = str_repeat('1', 16) . str_repeat('a', 48); +$_SESSION = [ + 'csrf_token' => $legacy, + 'csrf_token_time' => time(), +]; +$check(Csrf::ensureToken() === $legacy, 'a non-expired legacy hexadecimal token remains valid'); +$check(Csrf::validate($legacy), 'legacy validation remains byte-for-byte compatible'); + +Csrf::regenerate(); +$regenerated = Csrf::ensureToken(); +$check($isGroupedToken($regenerated), 'explicit regeneration uses the grouped format'); +$check($regenerated !== $legacy, 'explicit regeneration replaces the legacy token'); +$check(preg_match('/\d{13,}/', $regenerated) === 0, 'regenerated tokens cannot contain a PII-like decimal run'); + +echo "\n{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/issue-255-registration.spec.js b/tests/issue-255-registration.spec.js index f49f3b31c..2fbad975e 100644 --- a/tests/issue-255-registration.spec.js +++ b/tests/issue-255-registration.spec.js @@ -1,6 +1,6 @@ // @ts-check /** - * Issue #255 — configurable registration fields. 29-check E2E suite. + * Issue #255 — configurable registration fields. 30-check E2E suite. * * Coverage (all through the real browser): * 1-4 defaults + SERVER-side enforcement of each built-in requirement @@ -18,7 +18,7 @@ * 23-24 sanitization: a tag-carrying label is neutralised on save; a * script-carrying VALUE is escaped when rendered back (admin detail). * 25 an inactive (attivo=0) field disappears from the public form. - * 26-29 profile/admin-detail regressions and destructive type-change guard. + * 26-30 profile/admin-detail regressions and destructive type-change guard. */ const { test, expect } = require('@playwright/test'); @@ -117,7 +117,7 @@ function userCount(email) { return Number(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email='${email}'`)); } -test.describe.serial('Issue #255 — configurable registration fields (29 checks)', () => { +test.describe.serial('Issue #255 — configurable registration fields (30 checks)', () => { /** @type {import('@playwright/test').Page} */ let admin; /** @type {Map} setting key -> original HEX(setting_value) */ @@ -511,4 +511,32 @@ test.describe.serial('Issue #255 — configurable registration fields (29 checks expect(dbQuery(`SELECT tipo FROM registrazione_campi WHERE id=${id}`)).toBe('text'); await expect(admin.locator('body')).toContainText(/non puoi cambiare il tipo|cannot change the type|ne pouvez pas modifier le type|Typ eines Feldes/i); }); + + test('30. admin user details localize every stored sesso enum value', async ({ page }) => { + await setToggles(admin, false, false, false); + const email = `zz-255-sesso-${TOKEN}@example.test`; + await fillRegistration(page, { + fields: { nome: 'Sesso30', email, password: 'Password255!ok', password_confirm: 'Password255!ok' }, + }); + await page.selectOption('select[name="sesso"]', 'M'); + await submitNoValidate(page); + expect(userCount(email)).toBe(1); + + const uid = dbQuery(`SELECT id FROM utenti WHERE email='${email}'`); + expect(dbQuery(`SELECT sesso FROM utenti WHERE id=${uid}`)).toBe('M'); + + await admin.goto(`${BASE}/admin/users/edit/${uid}`); + /** @type {Record} */ + const expectedLabels = {}; + const genderValues = ['M', 'F', 'Altro']; + for (const value of genderValues) { + expectedLabels[value] = (await admin.locator(`select[name="sesso"] option[value="${value}"]`).innerText()).trim(); + } + + for (const value of genderValues) { + if (value !== 'M') dbQuery(`UPDATE utenti SET sesso='${value}' WHERE id=${uid}`); + await admin.goto(`${BASE}/admin/users/details/${uid}`); + await expect(admin.locator('[data-field="sesso"]')).toHaveText(expectedLabels[value]); + } + }); }); diff --git a/tests/issue-366-full-scenario.unit.php b/tests/issue-366-full-scenario.unit.php index d02f96b89..b14f62777 100644 --- a/tests/issue-366-full-scenario.unit.php +++ b/tests/issue-366-full-scenario.unit.php @@ -73,6 +73,11 @@ ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) : new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (\Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); @@ -97,6 +102,20 @@ function check(bool $cond, string $desc): void $today = DateHelper::today(); $d = static fn (int $offsetDays): string => date('Y-m-d', strtotime($today . ($offsetDays >= 0 ? " +{$offsetDays} days" : ' ' . $offsetDays . ' days'))); +$withDatabaseDate = static function (string $date, callable $callback) use ($db): mixed { + $safeDate = $db->real_escape_string($date); + $db->query("SET timestamp = UNIX_TIMESTAMP('{$safeDate} 12:00:00')"); + // The circulation triggers read the connection-bound application date + // before falling back to CURRENT_DATE(), so warping the clock must warp + // the bound date too — and restore the real application day afterwards. + $db->query("SET @pinakes_application_date = '{$safeDate}'"); + try { + return $callback(); + } finally { + $db->query('SET timestamp = 0'); + \App\Support\DateHelper::synchronizeDatabaseSession($db); + } +}; $settingsRow = static function (string $key, string $default) use ($db): string { $stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = ?"); @@ -421,29 +440,47 @@ function check(bool $cond, string $desc): void check(($loanRow($loanD)['stato'] ?? '') === 'in_ritardo', 'D1: overdue sibling flipped first'); check(($loanRow($resD)['stato'] ?? '') === 'da_ritirare', 'D1: reservation on the genuinely free copy IS promoted'); - // D2: 2 copies but the reservation is pinned to the copy still out. It must - // stay prenotato; a staff reschedule (future start) keeps it demoted; once - // the pinned copy returns and the window arrives, it promotes. + // D2: 2 copies but the reservation is pinned to the copy still out. The + // activation sweep must repair the stale pin and use the free sibling; + // a later staff reschedule still demotes it coherently. $userE = $mkUser(); $userE2 = $mkUser(); [$bookE, [$copyEa, $copyEb]] = $mkBook(2); - $loanE = $mkLoan($bookE, $copyEa, $userE, 'in_corso', $d(-30), $d(-5)); + // Seed predecessor first while it is still due today, then its disjoint + // successor. Returning the DB clock to today reproduces the conflict that + // arose only because the physical copy was not returned on time. + [$loanE, $resE] = $withDatabaseDate( + $d(-5), + static function () use ($mkLoan, $bookE, $copyEa, $userE, $userE2, $d): array { + $previous = $mkLoan($bookE, $copyEa, $userE, 'in_corso', $d(-30), $d(-5)); + $next = $mkLoan($bookE, $copyEa, $userE2, 'prenotato', $d(-1), $d(20)); + return [$previous, $next]; + } + ); $setCopyState($copyEa, 'prestato'); - $resE = $mkLoan($bookE, $copyEa, $userE2, 'prenotato', $d(-1), $d(20)); $svc->activateScheduledLoans(); - check(($loanRow($resE)['stato'] ?? '') === 'prenotato', 'D2: reservation pinned to the out copy stays prenotato despite book-level room'); + $row = $loanRow($resE); + check( + ($row['stato'] ?? '') === 'da_ritirare' && (int) ($row['copia_id'] ?? 0) === $copyEb, + 'D2: reservation pinned to the out copy moves to the free sibling and becomes ready' + ); $resp = $callUpdate($resE, $userE2, $d(2), $d(20)); check(!str_contains($resp->getHeaderLine('Location'), 'error='), 'D2: rescheduling the pinned reservation succeeds'); $row = $loanRow($resE); check(($row['stato'] ?? '') === 'prenotato' && ($row['pickup_deadline'] ?? null) === null, 'D2: rescheduled pinned reservation stays prenotato, no deadline'); - $returnLoan($loanE, $copyEa, 'prenotato'); // copy back on the shelf, held for the reservation - $shiftLoan($resE, $d(0), null); // time passes: its start arrives + $returnLoan($loanE, $copyEa, 'disponibile'); // original physical copy returns normally + $shiftLoan($resE, $d(0), null); // time passes: its start arrives $svc->activateScheduledLoans(); $row = $loanRow($resE); - check(($row['stato'] ?? '') === 'da_ritirare' && !empty($row['pickup_deadline']), 'D2: pinned copy returned + window arrived => promotion'); + check( + ($row['stato'] ?? '') === 'da_ritirare' + && (int) ($row['copia_id'] ?? 0) === $copyEb + && !empty($row['pickup_deadline']), + 'D2: rescheduled sibling-backed hold promotes when its window arrives' + ); /* ---- E. CapacityService: date-overdue in_corso occupies open-ended --- */ $userF = $mkUser(); @@ -470,8 +507,14 @@ function check(bool $cond, string $desc): void $userG = $mkUser(); $userG2 = $mkUser(); [$bookG, [$copyG]] = $mkBook(1); - $loanG = $mkLoan($bookG, $copyG, $userG, 'in_corso', $d(-30), $d(-1)); - $stalePickupG = $mkLoan($bookG, $copyG, $userG2, 'da_ritirare', $d(0), $d(12), $d(3)); + [$loanG, $stalePickupG] = $withDatabaseDate( + $d(-1), + static function () use ($mkLoan, $bookG, $copyG, $userG, $userG2, $d): array { + $previous = $mkLoan($bookG, $copyG, $userG, 'in_corso', $d(-30), $d(-1)); + $next = $mkLoan($bookG, $copyG, $userG2, 'da_ritirare', $d(0), $d(12), $d(3)); + return [$previous, $next]; + } + ); $setCopyState($copyG, 'prestato'); $reservationCount = (int) $db->query("SELECT COUNT(*) FROM prenotazioni WHERE libro_id = {$bookG}")->fetch_row()[0]; diff --git a/tests/issue-366-trigger-recovery.unit.php b/tests/issue-366-trigger-recovery.unit.php index bafca4ff2..6cbe4f06a 100644 --- a/tests/issue-366-trigger-recovery.unit.php +++ b/tests/issue-366-trigger-recovery.unit.php @@ -29,6 +29,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); @@ -78,8 +83,11 @@ (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) VALUES (1, 1, '2026-07-27', '2026-08-18', 'in_ritardo', 1), (1, 1, '2026-08-19', '2026-08-31', 'da_ritirare', 1), - (1, 2, '2026-08-01', '2026-08-10', 'in_corso', 1), - (1, 2, '2026-08-12', '2026-08-20', 'prenotato', 1), + -- Keep this clean overlap fixture far in the future: after #366, an + -- `in_corso` row whose due date is before today is intentionally + -- open-ended, so hard-coded 2026 dates would already conflict in OLD. + (1, 2, '2099-08-01', '2099-08-25', 'in_corso', 1), + (1, 2, '2099-09-01', '2099-09-10', 'prenotato', 1), (1, 3, '2026-07-27', '2026-08-18', 'in_corso', 1), (1, 3, '2026-08-19', '2026-08-31', 'prenotato', 1)"); @@ -134,7 +142,7 @@ // a conflict that OLD did not have: the defence-in-depth trigger still blocks it. $newConflictBlocked = false; try { - $db->query("UPDATE `{$loans}` SET data_prestito = '2026-08-10' WHERE id = 4"); + $db->query("UPDATE `{$loans}` SET data_prestito = '2099-08-25' WHERE id = 4"); } catch (mysqli_sql_exception $e) { $newConflictBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); } diff --git a/tests/issues-333-334-336.unit.php b/tests/issues-333-334-336.unit.php index 0c1b815f8..54238da09 100644 --- a/tests/issues-333-334-336.unit.php +++ b/tests/issues-333-334-336.unit.php @@ -236,7 +236,7 @@ 'update() checks capacity on the newly-claimed windows through CapacityService' ); $check( - str_contains($updateSource, '$copyOverlap->bind_param(\'iiss\', $copyId, $id, $claimEnd, $claimStart)') + str_contains($updateSource, '$copyOverlap->bind_param(\'iisss\', $copyId, $id, $claimEnd, $applicationToday, $claimStart)') && str_contains($updateSource, '?error=loan_copy_conflict'), 'update() checks the assigned copy on the same newly-claimed windows with a dedicated error' ); @@ -265,7 +265,7 @@ $check($bulkSource !== '', 'applyBulkLoanExtension() source section extracted'); $check( str_contains($bulkSource, 'hasFreeCapacity($bookId, $extensionStart, $newDueDate') - && str_contains($bulkSource, '$copyOverlap->bind_param(\'iiss\', $copyId, $loanId, $newDueDate, $extensionStart)'), + && str_contains($bulkSource, '$copyOverlap->bind_param(\'iisss\', $copyId, $loanId, $newDueDate, $today, $extensionStart)'), 'bulk extension gates capacity AND copy overlap on the same added-days interval' ); $check( diff --git a/tests/loan-auto-approval-301-reservation-path.unit.php b/tests/loan-auto-approval-301-reservation-path.unit.php index 7d27a8d84..7e103e45a 100644 --- a/tests/loan-auto-approval-301-reservation-path.unit.php +++ b/tests/loan-auto-approval-301-reservation-path.unit.php @@ -45,6 +45,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); diff --git a/tests/loan-auto-approval-301.unit.php b/tests/loan-auto-approval-301.unit.php index 2e45cacdb..60c3be261 100644 --- a/tests/loan-auto-approval-301.unit.php +++ b/tests/loan-auto-approval-301.unit.php @@ -37,6 +37,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); @@ -46,6 +51,14 @@ // 'mail' driver never blocks, but we also don't want a stray SMTP config to hang. $db->query("UPDATE system_settings SET setting_value='mail' WHERE category='email' AND setting_key IN ('driver_mode','type')"); +// Make the email outcome DETERMINISTIC: sendWithRetry() short-circuits on +// Mailer::isSmtpReachable(), so seeding the cached probe with `false` makes the +// approval email fail identically everywhere (CI runners have no sendmail; dev +// machines may have one). The pickup-claim assertion below (09b) then tests the +// release contract instead of depending on the host's mail transport. +$smtpProbe = new ReflectionProperty(\App\Support\Mailer::class, 'smtpReachable'); +$smtpProbe->setValue(null, false); + $run = substr(hash('sha256', uniqid((string) getmypid(), true)), 0, 10); $titlePrefix = 'ZZAUTO301_' . $run; $emailDomain = '@auto301.test.local'; @@ -209,6 +222,27 @@ // 09 — reuses the pipeline: a copy is locked/assigned and the loan is activated. $check((int) $loanField($loanOn, 'attivo') === 1 && $loanField($loanOn, 'copia_id') !== null, '09 auto-approval assigns a physical copy and activates the loan (canonical pipeline)'); +// 09b — the pre-commit pickup claim must be SETTLED after the approval email +// attempt. The mailer is forced unreachable above, so the approval email +// deterministically fails: the claim taken before commit must then be +// RELEASED (sent=0, no dangling token) so the retry sweep can still deliver +// the missing announcement — a dangling claim would silently lose it forever. +// (When the email succeeds in production the claim stays sent=1 with the +// token cleared; that branch needs a deliverable transport and cannot be +// asserted portably here.) +// Le colonne di claim arrivano con la migrazione 0.7.64: garantiscile prima +// di leggerle, altrimenti su un DB non aggiornato $loanField() solleva +// "Unknown column" (MYSQLI_REPORT_STRICT) invece di un FAIL descrittivo. +if (!\App\Support\PickupNotificationSchema::ensure($db)) { + fwrite(STDERR, "FAIL: 09b requires the pickup_notification_* columns on prestiti (migration 0.7.64)\n"); + $cleanup(); + exit(1); +} +$check( + (int) $loanField($loanOn, 'pickup_notification_sent') === 0 + && $loanField($loanOn, 'pickup_notification_claim_token') === null, + '09b immediate auto-approval claims the pickup announcement before a retry sweep can duplicate it' +); // 10 — ON but no available copy: must not force-approve; request stays pending. $setAuto('1'); diff --git a/tests/loan-bulk-extension-capacity.unit.php b/tests/loan-bulk-extension-capacity.unit.php index 934aa6f56..6b86a825c 100644 --- a/tests/loan-bulk-extension-capacity.unit.php +++ b/tests/loan-bulk-extension-capacity.unit.php @@ -41,6 +41,11 @@ ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) : new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index a9210b294..775a0fc18 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -32,6 +32,8 @@ * cron's `data_scadenza < today` semantics. * 11. Every peripheral physical-copy writer must derive the book projection * inside the same transaction instead of forcing or repairing it later. + * 12. Every first-party circulation connection must publish DateHelper::today() + * to the overlap triggers, which retain CURRENT_DATE() for direct SQL only. */ $root = dirname(__DIR__); @@ -90,6 +92,14 @@ $demoSeed = $readSource('/scripts/seed-demo-catalog.php'); $bookClubRepo = $readSource('/storage/plugins/book-club/src/Repo.php'); $manualUpgrade = $readSource('/scripts/manual-upgrade.php'); +$dateHelper = $readSource('/app/Support/DateHelper.php'); +$containerConfig = $readSource('/config/container.php'); +$triggerSql = $readSource('/installer/database/triggers.sql'); +$maintenanceService = $readSource('/app/Support/MaintenanceService.php'); +$automaticNotificationsCron = $readSource('/cron/automatic-notifications.php'); +$fullMaintenanceCron = $readSource('/cron/full-maintenance.php'); +$legacyMaintenanceScript = $readSource('/scripts/maintenance.php'); +$cliDbBootstrap = $readSource('/scripts/_db_bootstrap.php'); $checks = []; @@ -156,7 +166,10 @@ $checks['occupied_ranges includes pendente-with-copy and prenotazioni'] = $rangesBody !== '' && str_contains($rangesBody, "stato = 'pendente' AND copia_id IS NOT NULL") - && str_contains($rangesBody, "WHEN stato = 'in_ritardo' THEN '9999-12-31'") + && str_contains($rangesBody, "stato = 'in_ritardo'") + && str_contains($rangesBody, "stato = 'in_corso' AND data_scadenza < ?") + && str_contains($rangesBody, "THEN '9999-12-31'") + && str_contains($rangesBody, "bind_param('si', \$today, \$libroId)") && str_contains($rangesBody, "'to' => \$row['occupied_until']") && str_contains($rangesBody, "FROM prenotazioni"); @@ -344,6 +357,25 @@ && $migrationLoopPos !== false && strpos($manualUpgrade, 'recalculateAllBookAvailability()') > $migrationLoopPos; +// 26. The trigger clock follows app.timezone on every first-party circulation +// connection. CURRENT_DATE() appears only in the two trigger-local fallback +// assignments used by uninitialized/direct SQL clients. +$checks['circulation triggers share the configured application day on every first-party connection'] = + str_contains($dateHelper, 'function synchronizeDatabaseSession(') + && str_contains($containerConfig, 'DateHelper::synchronizeDatabaseSession($mysqli)') + && str_contains($reservations, 'DateHelper::synchronizeDatabaseSession($this->db)') + && str_contains($maintenanceService, 'DateHelper::synchronizeDatabaseSession($db)') + && str_contains($automaticNotificationsCron, 'DateHelper::synchronizeDatabaseSession($db)') + && str_contains($fullMaintenanceCron, 'DateHelper::synchronizeDatabaseSession($db)') + && str_contains($legacyMaintenanceScript, 'DateHelper::synchronizeDatabaseSession($db)') + && str_contains($cliDbBootstrap, 'DateHelper::synchronizeDatabaseSession($db)') + && str_contains($expiredCron, 'pinakes_db_from_env()') + && substr_count( + $triggerSql, + 'SET application_today = COALESCE(@pinakes_application_date, CURRENT_DATE())' + ) === 2 + && !str_contains($triggerSql, 'data_scadenza < CURRENT_DATE()'); + $failed = 0; foreach ($checks as $label => $ok) { echo ($ok ? '[PASS] ' : '[FAIL] ') . $label . "\n"; diff --git a/tests/loan-edge-cases.unit.php b/tests/loan-edge-cases.unit.php index 118bc7e94..5af9fe007 100644 --- a/tests/loan-edge-cases.unit.php +++ b/tests/loan-edge-cases.unit.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Behavioral integration suite — 64 loan ("prestiti") edge cases for Pinakes. + * Behavioral integration suite — 66 loan ("prestiti") edge cases for Pinakes. * * Runs against the LIVE local MySQL but only ever touches data it creates, * marked with: book titles `ZZ_LOANEDGE_%`, copy numero_inventario `ZZLE-%`, @@ -17,7 +17,7 @@ * - installer/database/triggers.sql (copy-occupancy invariants) * * Run: php tests/loan-edge-cases.unit.php - * Exit: 0 only if all 64 pass; prints "ALL 64 PASS". + * Exit: 0 only if all 66 pass; prints "ALL 66 PASS". */ use App\Models\CopyRepository; @@ -28,6 +28,7 @@ use App\Support\DataIntegrity; use App\Support\DateHelper; use App\Support\IcsGenerator; +use App\Support\MaintenanceService; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Response as SlimResponse; @@ -83,6 +84,11 @@ function loadEnv(string $path): array exit(0); } $db->set_charset('utf8mb4'); +// Production writers bind the application-local date on every +// connection (container/cron/scripts bootstrap); the circulation +// triggers otherwise fall back to the database's UTC CURRENT_DATE(), +// which disagrees with app.timezone between 22:00 and 24:00 UTC. +\App\Support\DateHelper::synchronizeDatabaseSession($db); /* -------------------------------------------------------------------------- * Markers + cleanup @@ -139,7 +145,7 @@ function pass(string $desc): void { global $TESTNO; $TESTNO++; - printf("[%02d/64] PASS: %s\n", $TESTNO, $desc); + printf("[%02d/66] PASS: %s\n", $TESTNO, $desc); } function assertEq($exp, $got, string $msg): void @@ -677,7 +683,7 @@ function copyStato(int $id): string pass('misc: libri.stato is derived (recalc overwrites wrong value)'); /* ========================================================================== - * 53-64 Canonical capacity, schedules, integrity, calendars and reservation queues + * 53-66 Canonical capacity, schedules, integrity, calendars and reservation queues * ====================================================================== */ // 53: an unreturned overdue loan is open-ended for future capacity decisions. $b = mkBook('capacity_overdue_open'); @@ -913,9 +919,112 @@ function copyStato(int $id): string assertEq($copy, (int) ($promotedLoan['copia_id'] ?? 0), 'promoted queue row must link the freed physical copy'); pass('reservations: user cancellation immediately promotes and links the next queue row'); +// 65: queue selection must scan beyond an arbitrary batch of ineligible +// patrons without erasing their capacity commitments. Twenty-five suspended +// positions saturate 25 lendable copies, so position 26 stays queued until the +// temporarily unavailable twenty-sixth copy returns to circulation. +$b = mkBook('queue_beyond_ineligible_batch'); +$queueCopies = mkCopies($b, 26); +$queueStart = DateHelper::today(); +$queueEnd = (new \DateTimeImmutable($queueStart))->modify('+7 days')->format('Y-m-d'); +$queueStartDt = $queueStart . ' 00:00:00'; +$queueEndDt = $queueEnd . ' 23:59:59'; +$insertReservation = $db->prepare( + "INSERT INTO prenotazioni + (libro_id, utente_id, data_prenotazione, data_scadenza_prenotazione, + data_inizio_richiesta, data_fine_richiesta, queue_position, stato) + VALUES (?, ?, ?, ?, ?, ?, ?, 'attiva')" +); +$ineligibleReservationIds = []; +for ($position = 1; $position <= 25; $position++) { + $userId = mkUser(); + $db->query("UPDATE utenti SET stato = 'sospeso' WHERE id = {$userId}"); + $insertReservation->bind_param('iissssi', $b, $userId, $queueStartDt, $queueEndDt, $queueStart, $queueEnd, $position); + $insertReservation->execute(); + $ineligibleReservationIds[] = (int) $db->insert_id; +} +$eligibleUserId = mkUser(); +$eligiblePosition = 26; +$insertReservation->bind_param('iissssi', $b, $eligibleUserId, $queueStartDt, $queueEndDt, $queueStart, $queueEnd, $eligiblePosition); +$insertReservation->execute(); +$eligibleReservationId = (int) $db->insert_id; +$insertReservation->close(); +$queueCapacity = new CapacityService($db); +$temporarilyUnavailableCopy = $queueCopies[25]; +(new CopyRepository($db))->updateStatus($temporarilyUnavailableCopy, 'manutenzione'); +assertEq(25, $queueCapacity->totalCopies($b), 'one maintenance copy must reduce queue capacity from 26 to 25'); +assertEq(26, $queueCapacity->occupiedCount($b, $queueStart, $queueEnd), 'all 26 active queue rows must contribute to OCC before promotion'); +assertEq( + 25, + $queueCapacity->occupiedCount($b, $queueStart, $queueEnd, excludeReservationId: $eligibleReservationId), + 'the 25 suspended reservations must keep occupying all 25 lendable copies' +); +assertEq( + false, + $queueCapacity->hasFreeCapacity( + $b, + $queueStart, + $queueEnd, + excludeReservationId: $eligibleReservationId, + excludeReservationsAfterQueuePos: $eligiblePosition + ), + 'position 26 must see no capacity while the 25 earlier suspended commitments fill it' +); +$blockedPromotion = (new ReservationManager($db))->processBookAvailability($b); +$blockedEligibleState = (string) $db->query("SELECT stato FROM prenotazioni WHERE id = {$eligibleReservationId}")->fetch_row()[0]; +$blockedEligibleLoans = (int) $db->query("SELECT COUNT(*) FROM prestiti WHERE libro_id = {$b} AND utente_id = {$eligibleUserId}")->fetch_row()[0]; +assertEq(false, $blockedPromotion, 'queue scanning must not promote position 26 beyond the current capacity ceiling'); +assertEq('attiva', $blockedEligibleState, 'capacity-blocked position 26 must remain active in the queue'); +assertEq(0, $blockedEligibleLoans, 'a capacity-blocked queue row must not create a physical-copy hold'); + +(new CopyRepository($db))->updateStatus($temporarilyUnavailableCopy, 'disponibile'); +assertEq(26, $queueCapacity->totalCopies($b), 'restoring the maintenance copy must reopen the twenty-sixth capacity slot'); +assertEq( + true, + $queueCapacity->hasFreeCapacity( + $b, + $queueStart, + $queueEnd, + excludeReservationId: $eligibleReservationId, + excludeReservationsAfterQueuePos: $eligiblePosition + ), + 'position 26 becomes promotable only after physical capacity returns' +); +$promoted = (new ReservationManager($db))->processBookAvailability($b); +$eligibleState = (string) $db->query("SELECT stato FROM prenotazioni WHERE id = {$eligibleReservationId}")->fetch_row()[0]; +$ineligibleIdsSql = implode(',', $ineligibleReservationIds); +$ineligibleActive = (int) $db->query("SELECT COUNT(*) FROM prenotazioni WHERE id IN ({$ineligibleIdsSql}) AND stato = 'attiva'")->fetch_row()[0]; +$eligibleLoan = $db->query("SELECT COALESCE(copia_id, 0) AS copia_id, stato, attivo FROM prestiti WHERE libro_id = {$b} AND utente_id = {$eligibleUserId} ORDER BY id DESC LIMIT 1")->fetch_assoc(); +$eligibleCopy = (int) ($eligibleLoan['copia_id'] ?? 0); +assertEq(true, $promoted, 'eligible queue position after 25 suspended patrons must be promoted once capacity allows'); +assertEq('completata', $eligibleState, 'eligible queue row must become completed'); +assertEq(25, $ineligibleActive, 'ineligible rows must remain active for later account recovery'); +assertEq(true, in_array($eligibleCopy, $queueCopies, true), 'eligible row must receive one of the remaining physical copies'); +assertEq('pendente', (string) ($eligibleLoan['stato'] ?? ''), 'promotion must replace the queue row with a pending copy-bound hold'); +assertEq(0, (int) ($eligibleLoan['attivo'] ?? -1), 'the pending promoted hold remains inactive until approval'); +assertEq(26, $queueCapacity->occupiedCount($b, $queueStart, $queueEnd), '25 suspended reservations plus the promoted copy-bound hold must preserve OCC=26'); +assertEq(false, $queueCapacity->hasFreeCapacity($b, $queueStart, $queueEnd), 'promotion must not manufacture a twenty-seventh capacity slot'); +pass('reservations: suspended queue rows preserve OCC while scanning beyond position 25'); + +// 66: a scheduled hold pinned to an unavailable physical copy must move to a +// free sibling copy instead of remaining stuck forever. +$b = mkBook('scheduled_pinned_copy_fallback'); +[$pinnedCopy, $alternativeCopy] = mkCopies($b, 2); +$scheduledUser = mkUser(); +$scheduledStart = DateHelper::today(); +$scheduledEnd = (new \DateTimeImmutable($scheduledStart))->modify('+7 days')->format('Y-m-d'); +$scheduledLoan = loan($b, $pinnedCopy, $scheduledUser, $scheduledStart, $scheduledEnd, 'prenotato', 1); +(new CopyRepository($db))->updateStatus($pinnedCopy, 'manutenzione'); +(new MaintenanceService($db))->activateScheduledLoans(); +$activated = $db->query("SELECT stato, copia_id FROM prestiti WHERE id = {$scheduledLoan}")->fetch_assoc(); +assertEq('da_ritirare', (string) ($activated['stato'] ?? ''), 'scheduled loan must become ready for pickup'); +assertEq($alternativeCopy, (int) ($activated['copia_id'] ?? 0), 'scheduled loan must move to the free sibling copy'); +assertEq('manutenzione', copyStato($pinnedCopy), 'unavailable pinned copy state must remain untouched'); +pass('schedules: unavailable pinned copy falls back to a free sibling'); + /* ========================================================================== * Done * ====================================================================== */ cleanup($db); -echo "ALL 64 PASS\n"; +echo "ALL 66 PASS\n"; $db->close(); diff --git a/tests/loan-extension-281.unit.php b/tests/loan-extension-281.unit.php index f3473eaad..a9ed724dd 100644 --- a/tests/loan-extension-281.unit.php +++ b/tests/loan-extension-281.unit.php @@ -42,6 +42,11 @@ ? new mysqli(null, $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', 0, $socket) : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', (int) ($env['DB_PORT'] ?? 3306)); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (\Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); diff --git a/tests/loan-notification-availability.unit.php b/tests/loan-notification-availability.unit.php index 2449ecd76..364ad569f 100644 --- a/tests/loan-notification-availability.unit.php +++ b/tests/loan-notification-availability.unit.php @@ -32,6 +32,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — {$e->getMessage()}\n"); exit(1); @@ -89,6 +94,7 @@ $today = DateHelper::today(); $yesterday = (new DateTimeImmutable($today))->modify('-1 day')->format('Y-m-d'); $tomorrow = (new DateTimeImmutable($today))->modify('+1 day')->format('Y-m-d'); +$dayAfterTomorrow = (new DateTimeImmutable($today))->modify('+2 days')->format('Y-m-d'); $nextMonth = (new DateTimeImmutable($today))->modify('+30 days')->format('Y-m-d'); $service = new NotificationService($db); $passed = 0; @@ -104,6 +110,33 @@ $stmt->bind_param('iiiss', $overdueBook, $overdueCopy, $userId, $loanStart, $yesterday); $stmt->execute(); $stmt->close(); $check($service->hasActualAvailableCopy($overdueBook) === false, 'overdue unreturned copy is not advertised as available'); +$check($service->getNextAvailabilityDate($overdueBook) === null, 'open-ended overdue copy has no guessed next availability date'); + +[$staleBook, $staleCopy] = $makeBook(1); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo) VALUES (?,?,?,?,?,'in_corso','diretto',1)"); +$stmt->bind_param('iiiss', $staleBook, $staleCopy, $userId, $loanStart, $yesterday); +$stmt->execute(); $stmt->close(); +$staleReservationEnd = $nextMonth . ' 23:59:59'; +$stmt = $db->prepare("INSERT INTO prenotazioni (libro_id,utente_id,data_prenotazione,data_scadenza_prenotazione,data_inizio_richiesta,data_fine_richiesta,queue_position,stato) VALUES (?,?,NOW(),?,?,?,1,'attiva')"); +$stmt->bind_param('iisss', $staleBook, $userId, $staleReservationEnd, $today, $nextMonth); +$stmt->execute(); $stmt->close(); +$check( + $service->getNextAvailabilityDate($staleBook) === null, + 'date-overdue in_corso remains open-ended even beside a finite reservation' +); + +[$multiBook, $multiCopyA] = $makeBook(2); +$multiCopyB = (new CopyRepository($db))->create($multiBook, 'ZZNOTIFY-' . $run . '-MULTI-B', 'disponibile'); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo) VALUES (?,?,?,?,?,'in_corso','diretto',1)"); +$stmt->bind_param('iiiss', $multiBook, $multiCopyA, $userId, $loanStart, $yesterday); +$stmt->execute(); $stmt->close(); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo) VALUES (?,?,?,?,?,'in_corso','diretto',1)"); +$stmt->bind_param('iiiss', $multiBook, $multiCopyB, $userId, $yesterday, $tomorrow); +$stmt->execute(); $stmt->close(); +$check( + $service->getNextAvailabilityDate($multiBook) === $dayAfterTomorrow, + 'a finite sibling copy still yields its real release date beside an open-ended loan' +); [$futureBook, $futureCopy] = $makeBook(1); $stmt = $db->prepare("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo) VALUES (?,?,?,?,?,'prenotato','diretto',1)"); diff --git a/tests/loan-overlap.spec.js b/tests/loan-overlap.spec.js index 7811a3edf..50f1b8a83 100644 --- a/tests/loan-overlap.spec.js +++ b/tests/loan-overlap.spec.js @@ -262,7 +262,11 @@ test.describe.serial('Loan overlap model (#157) — 39 scenarios', () => { async function adminStoreLoan(page, { bookId, userId, start, end, copyCode = '' }) { const csrf = await page.evaluate(() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); return page.evaluate(async ({ base, bookId, userId, start, end, copyCode, csrf }) => { - const body = new URLSearchParams({ csrf_token: csrf, libro_id: String(bookId), utente_id: String(userId), data_prestito: start, data_scadenza: end }); + // The loan INSERT is replay-guarded by a one-time submission token that + // only the rendered form carries — fetch the form first, like a browser. + const formHtml = await (await fetch(base + '/admin/loans/create', { credentials: 'same-origin' })).text(); + const submissionToken = (formHtml.match(/name="loan_submission_token"\s+value="([a-f0-9]{64})"/) || [])[1] || ''; + const body = new URLSearchParams({ csrf_token: csrf, loan_submission_token: submissionToken, libro_id: String(bookId), utente_id: String(userId), data_prestito: start, data_scadenza: end }); if (copyCode) body.set('copy_code', copyCode); // follow the redirect so r.url carries the ?error= / ?success= query const r = await fetch(base + '/admin/prestiti/crea', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }); diff --git a/tests/loan-recall-360-behavior.unit.php b/tests/loan-recall-360-behavior.unit.php index 6f7954e51..c15d24754 100644 --- a/tests/loan-recall-360-behavior.unit.php +++ b/tests/loan-recall-360-behavior.unit.php @@ -47,6 +47,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); diff --git a/tests/loan-recall-360.unit.php b/tests/loan-recall-360.unit.php index 80729c201..c7d87cdc6 100644 --- a/tests/loan-recall-360.unit.php +++ b/tests/loan-recall-360.unit.php @@ -215,7 +215,7 @@ $check( substr_count((string) $controllerSource, 'recall_count = 0') >= 4 && substr_count((string) $controllerSource, 'last_recall_at = NULL') >= 4 - && str_contains((string) $controllerSource, "elseif (\$newUserId !== (int) \$locked['utente_id'])"), + && str_contains((string) $controllerSource, "if (\$newUserId !== (int) \$locked['utente_id'])"), 'due-date changes, renewals, bulk extensions, and user reassignment reset the recall cycle' ); $check( diff --git a/tests/loan-reservation-consistency.unit.php b/tests/loan-reservation-consistency.unit.php index 490ddec78..5143cacb1 100644 --- a/tests/loan-reservation-consistency.unit.php +++ b/tests/loan-reservation-consistency.unit.php @@ -106,9 +106,15 @@ function assertNotContainsText(string $needle, string $haystack, string $message 'Wishlist availability must delegate to the canonical capacity authority' ); assertContainsText( - 'COALESCE(data_inizio_richiesta, DATE(data_scadenza_prenotazione)) IS NOT NULL', + '->firstAvailableDate($bookId, $today)', $notificationService, - 'next-availability lookup must retain legacy NULL-start reservations' + 'next-availability lookup must delegate to the canonical interval sweep' +); +$capacityService = readFileOrFail($root . '/app/Services/CapacityService.php'); +assertContainsText( + 'COALESCE(r.data_inizio_richiesta, DATE(r.data_scadenza_prenotazione))', + $capacityService, + 'canonical next-availability intervals must retain legacy NULL-start reservations' ); assertContainsText("AND p.attivo = 1", $notificationService, 'Automatic loan notifications must ignore closed rows'); assertContainsText("AND attivo = 1 AND stato = 'in_corso'", $notificationService, 'Expiration-warning claim must revalidate the loan lifecycle'); diff --git a/tests/loan-reservation-real-world-25.unit.php b/tests/loan-reservation-real-world-25.unit.php index 26cd743b9..c13b594ed 100644 --- a/tests/loan-reservation-real-world-25.unit.php +++ b/tests/loan-reservation-real-world-25.unit.php @@ -59,6 +59,11 @@ ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) : new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (\Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for the 25 real-world scenarios: {$e->getMessage()}\n"); exit(1); @@ -95,6 +100,20 @@ public function sendTemplate( 'Y-m-d', strtotime($today . ($offset >= 0 ? " +{$offset} days" : " {$offset} days")) ); +$withDatabaseDate = static function (string $date, callable $callback) use ($db): mixed { + $safeDate = $db->real_escape_string($date); + $db->query("SET timestamp = UNIX_TIMESTAMP('{$safeDate} 12:00:00')"); + // The circulation triggers read the connection-bound application date + // before falling back to CURRENT_DATE(), so warping the clock must warp + // the bound date too — and restore the real application day afterwards. + $db->query("SET @pinakes_application_date = '{$safeDate}'"); + try { + return $callback(); + } finally { + $db->query('SET timestamp = 0'); + \App\Support\DateHelper::synchronizeDatabaseSession($db); + } +}; $cleanup = static function () use ($db, $titlePrefix, $run, $emailSuffix): void { $titleLike = $db->real_escape_string($titlePrefix) . '%'; @@ -321,26 +340,40 @@ public function sendTemplate( && $row['pickup_deadline'] <= $row['data_scadenza']; }); - $scenario('a reservation pinned to an out copy stays queued even when a sibling copy is free', function () use ($mkUser, $mkBook, $mkLoan, $setCopyState, $maintenance, $loanRow, $d): bool { + $scenario('a reservation pinned to an out copy moves to a free sibling before pickup', function () use ($mkUser, $mkBook, $mkLoan, $setCopyState, $maintenance, $loanRow, $d, $withDatabaseDate): bool { $old = $mkUser('s03a'); $next = $mkUser('s03b'); [$bookId, [$outCopy, $freeCopy]] = $mkBook('s03', ['disponibile', 'disponibile']); - $mkLoan($bookId, $outCopy, $old['id'], 'in_corso', $d(-20), $d(-1)); + [, $successor] = $withDatabaseDate( + $d(-1), + static function () use ($mkLoan, $bookId, $outCopy, $old, $next, $d): array { + $previous = $mkLoan($bookId, $outCopy, $old['id'], 'in_corso', $d(-20), $d(-1)); + $scheduled = $mkLoan($bookId, $outCopy, $next['id'], 'prenotato', $d(0), $d(10)); + return [$previous, $scheduled]; + } + ); $setCopyState($outCopy, 'prestato'); - $successor = $mkLoan($bookId, $outCopy, $next['id'], 'prenotato', $d(0), $d(10)); $maintenance->updateOverdueLoans(); $maintenance->activateScheduledLoans(); $row = $loanRow($successor); - return ($row['stato'] ?? '') === 'prenotato' && $freeCopy > 0; + return ($row['stato'] ?? '') === 'da_ritirare' + && (int) ($row['copia_id'] ?? 0) === $freeCopy + && $freeCopy > 0; }); - $scenario('a pinned reservation promotes as soon as its own copy is returned', function () use ($mkUser, $mkBook, $mkLoan, $setCopyState, $returnLoan, $maintenance, $loanRow, $d): bool { + $scenario('a pinned reservation promotes as soon as its own copy is returned', function () use ($mkUser, $mkBook, $mkLoan, $setCopyState, $returnLoan, $maintenance, $loanRow, $d, $withDatabaseDate): bool { $old = $mkUser('s04a'); $next = $mkUser('s04b'); [$bookId, [$copyId, $unusedCopy]] = $mkBook('s04', ['disponibile', 'disponibile']); - $predecessor = $mkLoan($bookId, $copyId, $old['id'], 'in_corso', $d(-20), $d(-1)); + [$predecessor, $successor] = $withDatabaseDate( + $d(-1), + static function () use ($mkLoan, $bookId, $copyId, $old, $next, $d): array { + $previous = $mkLoan($bookId, $copyId, $old['id'], 'in_corso', $d(-20), $d(-1)); + $scheduled = $mkLoan($bookId, $copyId, $next['id'], 'prenotato', $d(0), $d(10)); + return [$previous, $scheduled]; + } + ); $setCopyState($copyId, 'prestato'); - $successor = $mkLoan($bookId, $copyId, $next['id'], 'prenotato', $d(0), $d(10)); $returnLoan($predecessor, $copyId, 'prenotato'); $maintenance->activateScheduledLoans(); return ($loanRow($successor)['stato'] ?? '') === 'da_ritirare' && $unusedCopy > 0; diff --git a/tests/loan-review-0764.spec.js b/tests/loan-review-0764.spec.js new file mode 100644 index 000000000..54e248ed0 --- /dev/null +++ b/tests/loan-review-0764.spec.js @@ -0,0 +1,238 @@ +// @ts-check +// +// E2E — Loan edit-flow review for 0.7.64 (F1: due-date backdating guard). +// +// Exercises the REAL admin edit-loan flow (browser + Apache + PrestitiController +// ::update + the per-copy circulation triggers). The reviewed change: actively +// backdating an active loan's due date into the past must fail with the CLEAR +// `expired_window` message and redirect to /admin/loans?error=expired_window — +// never the opaque `loan_update_failed` — while editing an already-overdue loan +// whose due date is left UNCHANGED must still succeed (the guard only fires when +// the due date is actually being moved into the past). +// +// Self-provisions its own admin, borrower, book and copy (tokenised) and cleans +// everything up in afterAll. Requires a real installed DB — the runner +// (/tmp/run-e2e.sh) supplies E2E_DB_* and the base URL. + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); + +const BASE = process.env.E2E_BASE_URL || process.env.APP_URL || 'http://localhost:8081'; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip( + !DB_USER || !DB_NAME, + 'E2E DB credentials not configured (set E2E_DB_USER, E2E_DB_NAME via /tmp/run-e2e.sh)', +); + +// Serial: the tests share one loan row and reseed it between runs. +test.describe.configure({ mode: 'serial' }); + +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', + timeout: 15000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +/** Single-quote + escape a value for inlining as a SQL string literal. */ +const S = (v) => "'" + String(v).replace(/'/g, "''") + "'"; + +/** bcrypt hash the app's password_verify() will accept. */ +function phpPasswordHash(pw) { + return execFileSync('php', ['-r', `echo password_hash(${JSON.stringify(pw)}, PASSWORD_DEFAULT);`], { + encoding: 'utf-8', + timeout: 15000, + }).trim(); +} + +/** The application's local "today" (DateHelper::today) — matches the update() guard. */ +function appToday() { + return execFileSync('php', ['-r', "require 'vendor/autoload.php'; echo App\\Support\\DateHelper::today();"], { + encoding: 'utf-8', + timeout: 15000, + cwd: process.cwd(), + }).trim(); +} + +/** yyyy-mm-dd offset from the app's today. */ +function dayFromToday(base, offsetDays) { + const dt = new Date(base + 'T00:00:00Z'); + dt.setUTCDate(dt.getUTCDate() + offsetDays); + return dt.toISOString().slice(0, 10); +} + +const RUN = crypto.randomBytes(4).toString('hex'); +const DOMAIN = '@0764rev.test.local'; +const ADMIN_EMAIL = `rev-admin-${RUN}${DOMAIN}`; +const ADMIN_PASS = 'Rev0764Pass!'; + +/** @type {{adminId:number, borrowerId:number, bookId:number, copyId:number, today:string}} */ +const fx = { adminId: 0, borrowerId: 0, bookId: 0, copyId: 0, today: '' }; + +async function loginAdmin(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('form button[type="submit"], button[type="submit"]').first().click(); + await page.waitForURL((url) => !url.toString().includes('/accedi'), { timeout: 15000 }); +} + +/** Delete any loan on the shared copy and seed exactly one active loan. Returns its id. */ +function seedLoan(startDate, dueDate, stato = 'in_corso') { + dbQuery(`DELETE FROM prestiti WHERE libro_id=${fx.bookId}`); + dbQuery(`UPDATE copie SET stato='disponibile' WHERE id=${fx.copyId}`); + dbQuery( + `INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) + VALUES (${fx.bookId}, ${fx.copyId}, ${fx.borrowerId}, ${S(startDate)}, ${S(dueDate)}, ${S(stato)}, 'diretto', 1)`, + ); + return Number(dbQuery(`SELECT id FROM prestiti WHERE libro_id=${fx.bookId} ORDER BY id DESC LIMIT 1`)); +} + +/** Open the edit form, set the due date, submit, and return the landing URL. */ +async function editDueDate(page, loanId, newDue, { newStart = null } = {}) { + await page.goto(`${BASE}/admin/loans/edit/${loanId}`); + await page.waitForLoadState('domcontentloaded'); + const form = page.locator('form[action*="/admin/loans/update/"]'); + await form.waitFor({ timeout: 15000 }); + // The global flatpickr-init converts these type="date" inputs (altInput:true), + // hiding the real name="data_*" input and showing a formatted altInput. Drive + // the underlying value through flatpickr's own API (dateFormat 'Y-m-d'). + await page.waitForFunction( + () => { + const el = document.querySelector('form[action*="/admin/loans/update/"] input[name="data_scadenza"]'); + return !!(el && el._flatpickr); + }, + null, + { timeout: 15000 }, + ); + const setDate = async (name, value) => { + await form.locator(`input[name="${name}"]`).evaluate((el, v) => { + if (el._flatpickr) { + el._flatpickr.setDate(v, true); + } else { + el.value = v; + el.dispatchEvent(new Event('change', { bubbles: true })); + } + }, value); + }; + if (newStart !== null) { + await setDate('data_prestito', newStart); + } + if (newDue !== null) { + await setDate('data_scadenza', newDue); + } + await form.locator('button[type="submit"]').first().click(); + await page.waitForURL((url) => url.pathname.endsWith('/admin/loans'), { timeout: 15000 }); + return page.url(); +} + +test.beforeAll(() => { + fx.today = appToday(); + + const adminHash = phpPasswordHash(ADMIN_PASS); + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S('REVADM' + RUN.toUpperCase())}, 'Admin', ${S('Rev' + RUN)}, ${S(ADMIN_EMAIL)}, ${S(adminHash)}, 'admin', 'attivo', 1, 1, 'it_IT')`, + ); + fx.adminId = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(ADMIN_EMAIL)} LIMIT 1`)); + + const borrowerHash = phpPasswordHash('Borrow0764!'); + const borrowerEmail = `rev-b-${RUN}${DOMAIN}`; + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S('REVB' + RUN.toUpperCase())}, 'Borrower', ${S('Rev' + RUN)}, ${S(borrowerEmail)}, ${S(borrowerHash)}, 'standard', 'attivo', 1, 1, 'it_IT')`, + ); + fx.borrowerId = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(borrowerEmail)} LIMIT 1`)); + + dbQuery(`INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (${S('ZZ0764REV ' + RUN + ' Edit Guard Book')}, 1, 1)`); + fx.bookId = Number(dbQuery(`SELECT id FROM libri WHERE titolo=${S('ZZ0764REV ' + RUN + ' Edit Guard Book')} ORDER BY id DESC LIMIT 1`)); + const inv = `ZZ0764REV-${RUN}-A`; + dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.bookId}, ${S(inv)}, 'disponibile')`); + fx.copyId = Number(dbQuery(`SELECT id FROM copie WHERE numero_inventario=${S(inv)} LIMIT 1`)); +}); + +test.afterAll(() => { + if (fx.bookId) { + dbQuery(`DELETE FROM prestiti WHERE libro_id=${fx.bookId}`); + dbQuery(`DELETE FROM copie WHERE libro_id=${fx.bookId}`); + dbQuery(`DELETE FROM libri WHERE id=${fx.bookId}`); + } + dbQuery(`DELETE FROM user_sessions WHERE utente_id IN (SELECT id FROM utenti WHERE email LIKE ${S('%' + RUN + DOMAIN)})`); + dbQuery(`DELETE FROM utenti WHERE email LIKE ${S('%' + RUN + DOMAIN)}`); +}); + +test('1. backdating an active loan due date redirects to error=expired_window', async ({ page }) => { + const loanId = seedLoan(fx.today, dayFromToday(fx.today, 14)); + await loginAdmin(page); + const url = await editDueDate(page, loanId, dayFromToday(fx.today, -5), { newStart: dayFromToday(fx.today, -10) }); + expect(url).toContain('error=expired_window'); +}); + +test('2. the expired_window banner shows the CLEAR message, not the opaque update-failed default', async ({ page }) => { + const loanId = seedLoan(fx.today, dayFromToday(fx.today, 14)); + await loginAdmin(page); + await editDueDate(page, loanId, dayFromToday(fx.today, -5), { newStart: dayFromToday(fx.today, -10) }); + const banner = page.locator('[role="alert"]'); + await expect(banner).toContainText('non può essere nel passato'); + // The opaque generic fallback ("Errore durante l'aggiornamento del prestito") + // must NOT be what the user sees. + await expect(banner).not.toContainText("Errore durante l'aggiornamento del prestito"); +}); + +test('3. the rejected backdate leaves the stored due date UNCHANGED', async ({ page }) => { + const originalDue = dayFromToday(fx.today, 14); + const loanId = seedLoan(fx.today, originalDue); + await loginAdmin(page); + await editDueDate(page, loanId, dayFromToday(fx.today, -5), { newStart: dayFromToday(fx.today, -10) }); + const stored = dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`); + expect(stored).toBe(originalDue); +}); + +test('4. editing an already-overdue loan without moving its due date still SUCCEEDS', async ({ page }) => { + // Due date already in the past, left unchanged; only the start date is nudged. + const overdueDue = dayFromToday(fx.today, -3); + const loanId = seedLoan(dayFromToday(fx.today, -10), overdueDue); + await loginAdmin(page); + const url = await editDueDate(page, loanId, overdueDue, { newStart: dayFromToday(fx.today, -12) }); + expect(url).not.toContain('error='); + // The loan is still active and its (past) due date is intact — the guard did + // not false-block an unrelated edit. + const row = dbQuery(`SELECT attivo, data_scadenza FROM prestiti WHERE id=${loanId}`).split('\t'); + expect(row[0]).toBe('1'); + expect(row[1]).toBe(overdueDue); +}); + +test('5. moving the due date to a valid FUTURE date succeeds and persists', async ({ page }) => { + const loanId = seedLoan(fx.today, dayFromToday(fx.today, 14)); + const newDue = dayFromToday(fx.today, 40); + await loginAdmin(page); + const url = await editDueDate(page, loanId, newDue); + expect(url).not.toContain('error='); + expect(dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`)).toBe(newDue); +}); + +test('6. an inverted range (due before start) is rejected with the distinct invalid_dates code', async ({ page }) => { + const loanId = seedLoan(fx.today, dayFromToday(fx.today, 14)); + await loginAdmin(page); + // Keep the due date in the FUTURE but move the start date after it, so this is + // an inverted-range rejection (invalid_dates), never the expired_window guard. + const url = await editDueDate(page, loanId, dayFromToday(fx.today, 5), { newStart: dayFromToday(fx.today, 20) }); + expect(url).toContain('error=invalid_dates'); + expect(url).not.toContain('error=expired_window'); +}); diff --git a/tests/loan-review-0764.unit.php b/tests/loan-review-0764.unit.php new file mode 100644 index 000000000..868889615 --- /dev/null +++ b/tests/loan-review-0764.unit.php @@ -0,0 +1,405 @@ +_% + * - copy numero_inv. ZZ0764--% + * - user email zz0764rev+-@test.local + * Cleanup is scoped strictly by those markers (FK-safe order) and runs at + * start, at end, and on any failure. The touched global loan settings are + * captured and restored byte-for-byte. + * + * Locks the intended behaviour reviewed for 0.7.64: + * B (point 2) CopyRepository::getAvailableByBookIdForDateRange still offers a + * copy that is physically out today when the requested window is + * date-disjoint, and hides one whose loan really overlaps, plus + * the c.stato NOT IN (...) out-of-circulation filter. + * C (point 3) the per-copy circulation trigger treats a stale in_corso loan + * (overdue by date, not yet flipped) as open-ended and rejects a + * second loan on the SAME copy, while a DIFFERENT copy is allowed. + * D (point 4) multiplicity ON permits one borrower to hold DISTINCT copies of + * one title, but never two overlapping loans of the SAME copy. + * E (point 5) the ready-for-pickup claim UPDATE is atomic: only one attempt + * wins, a finalized claim is never re-selected, a stale lease is + * fairly re-claimable. + * F (point 6) NCIP checkout keeps strict borrower/title uniqueness even when + * the relaxed multiplicity setting is ON. + * + * Requires E2E_DB_NAME (a guard aborts otherwise — this suite writes to the DB). + * Run: + * E2E_DB_SOCKET=/opt/homebrew/var/mysql/mysql.sock E2E_DB_USER=... \ + * E2E_DB_PASS="$PASS" E2E_DB_NAME=fabiodal_biblioteca \ + * php tests/loan-review-0764.unit.php + */ + +use App\Models\CopyRepository; +use App\Models\SettingsRepository; +use App\Support\ConfigStore; +use App\Support\DateHelper; +use App\Support\LoanMultiplicityPolicy; +use App\Support\PickupNotificationSchema; + +$root = dirname(__DIR__); +require $root . '/vendor/autoload.php'; + +// Surface every mysqli/trigger error (SIGNAL) as an exception we can assert on. +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +/* -------------------------------------------------------------------------- + * .env loading + safety guard (this suite performs cleanup DELETEs) + * ------------------------------------------------------------------------ */ +$env = []; +foreach (preg_split('/\r?\n/', (string) @file_get_contents($root . '/.env')) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$k, $v] = explode('=', $line, 2); + $env[trim($k)] = trim(trim($v), "\"'"); +} + +$dbName = getenv('E2E_DB_NAME') ?: ''; +if ($dbName === '') { + fwrite(STDERR, "FAIL: refusing to run — this suite writes to and cleans up the DB. Export E2E_DB_NAME (plus E2E_DB_SOCKET or E2E_DB_HOST/E2E_DB_USER/E2E_DB_PASS) pointing at the test database.\n"); + exit(1); +} +$dbHost = getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'); +$dbUser = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$dbPass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$dbPort = (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306)); +$socket = getenv('E2E_DB_SOCKET') ?: (getenv('E2E_DB_HOST') ? '' : ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock')); + +try { + $db = $socket !== '' && file_exists($socket) + ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) + : new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort); + $db->set_charset('utf8mb4'); + // App writers bind the application-local date on every connection; the + // circulation triggers otherwise fall back to the DB's UTC CURRENT_DATE(). + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +/* -------------------------------------------------------------------------- + * Harness + * ------------------------------------------------------------------------ */ +$RUN = bin2hex(random_bytes(5)); +$TITLE_LIKE = "ZZ_0764REV_{$RUN}_%"; +$INV_LIKE = "ZZ0764-{$RUN}-%"; +$EMAIL_LIKE = "zz0764rev+{$RUN}-%@test.local"; + +$today = DateHelper::today(); +$d = static fn(int $n): string => (new DateTimeImmutable($today))->modify(($n >= 0 ? '+' : '-') . abs($n) . ' days')->format('Y-m-d'); + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + printf("[%s] %s\n", $ok ? 'PASS' : 'FAIL', $label); + $ok ? $passed++ : $failed++; +}; + +$cleanup = static function () use ($db, $RUN): void { + $like = "ZZ_0764REV_{$RUN}\\_%"; + $db->query("DELETE p FROM prestiti p JOIN libri l ON p.libro_id = l.id WHERE l.titolo LIKE '{$like}'"); + $db->query("DELETE c FROM copie c JOIN libri l ON c.libro_id = l.id WHERE l.titolo LIKE '{$like}'"); + $db->query("DELETE FROM copie WHERE numero_inventario LIKE 'ZZ0764-{$RUN}-%'"); + $db->query("DELETE FROM libri WHERE titolo LIKE '{$like}'"); + $db->query("DELETE FROM utenti WHERE email LIKE 'zz0764rev+{$RUN}-%@test.local'"); +}; + +/** Capture a loans setting for byte-exact restore. @return array{exists:bool,value:?string} */ +$captureSetting = static function (string $key) use ($db): array { + $stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key=? LIMIT 1"); + $stmt->bind_param('s', $key); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return ['exists' => $row !== null, 'value' => $row !== null ? ($row['setting_value'] === null ? null : (string) $row['setting_value']) : null]; +}; +$restoreSetting = static function (string $key, array $orig) use ($db): void { + if (!$orig['exists']) { + $stmt = $db->prepare("DELETE FROM system_settings WHERE category='loans' AND setting_key=?"); + $stmt->bind_param('s', $key); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); + return; + } + $stmt = $db->prepare("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', ?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); + $value = $orig['value']; + $stmt->bind_param('ss', $key, $value); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); +}; + +$seq = 0; +$mkUser = static function () use ($db, $RUN, &$seq): int { + $seq++; + $card = 'ZZ0764' . strtoupper($RUN) . $seq; + $email = "zz0764rev+{$RUN}-{$seq}@test.local"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) VALUES (?, 'ZZ', '0764Rev', ?, 'x', 'standard', 'attivo', 1)"); + $stmt->bind_param('ss', $card, $email); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +$bookSeq = 0; +/** @return array{0:int,1:list} */ +$mkBook = static function (int $copies, string $copyStato = 'disponibile') use ($db, $RUN, &$bookSeq): array { + $bookSeq++; + $title = "ZZ_0764REV_{$RUN}_{$bookSeq}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (?, ?, ?)"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $bookId = (int) $db->insert_id; + $stmt->close(); + + $ids = []; + for ($i = 1; $i <= $copies; $i++) { + $inv = "ZZ0764-{$RUN}-{$bookId}-{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, ?)"); + $stmt->bind_param('iss', $bookId, $inv, $copyStato); + $stmt->execute(); + $ids[] = (int) $db->insert_id; + $stmt->close(); + } + return [$bookId, $ids]; +}; + +$loan = static function (int $bookId, ?int $copyId, int $userId, string $from, string $to, string $stato = 'in_corso', int $attivo = 1) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (?, ?, ?, ?, ?, ?, 'diretto', ?)"); + $stmt->bind_param('iiisssi', $bookId, $copyId, $userId, $from, $to, $stato, $attivo); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +set_exception_handler(static function (Throwable $e) use ($db, $cleanup): void { + try { $cleanup(); } catch (Throwable $ignored) {} + fwrite(STDERR, "UNCAUGHT: {$e->getMessage()}\n{$e->getTraceAsString()}\n"); + exit(1); +}); + +$origMultiplicity = $captureSetting('allow_multiple_loans_same_book'); +$origMaxLoans = $captureSetting('max_active_loans_per_user'); +$sessionBefore = $_SESSION ?? []; +$cleanup(); + +$settings = new SettingsRepository($db); +// Keep the independent per-user cap out of these fixtures. +$settings->set('loans', 'max_active_loans_per_user', '0'); +ConfigStore::clearCache(); + +try { + /* ================================================================== + * B (point 2) — getAvailableByBookIdForDateRange: out-but-disjoint + * copy is still offered; a real overlap is hidden; out-of-circulation + * status is filtered. + * ================================================================ */ + $repo = new CopyRepository($db); + [$b, $copies] = $mkBook(4); + [$cDisjoint, $cOverlap, $cManut, $cFree] = $copies; + $u1 = $mkUser(); + $u2 = $mkUser(); + + // Copy 0: physically out today (loan today..+10), DATE-DISJOINT from the + // future request window +20..+30. + $loan($b, $cDisjoint, $u1, $d(0), $d(10), 'in_corso', 1); + $db->query("UPDATE copie SET stato='prestato' WHERE id={$cDisjoint}"); + // Copy 1: a loan +18..+25 that REALLY overlaps the +20..+30 window. + $loan($b, $cOverlap, $u2, $d(18), $d(25), 'in_corso', 1); + $db->query("UPDATE copie SET stato='prestato' WHERE id={$cOverlap}"); + // Copy 2: out of circulation (manutenzione) with no loan at all. + $db->query("UPDATE copie SET stato='manutenzione' WHERE id={$cManut}"); + // Copy 3: plain free copy. + + $available = $repo->getAvailableByBookIdForDateRange($b, $d(20), $d(30)); + $availableIds = array_map(static fn($r) => (int) $r['id'], $available); + + $check(in_array($cDisjoint, $availableIds, true), 'B1 out-today copy with a date-disjoint loan is still assignable for a future window'); + $check(!in_array($cOverlap, $availableIds, true), 'B2 a copy whose loan truly overlaps the window is excluded'); + $check(!in_array($cManut, $availableIds, true), 'B3 an out-of-circulation (manutenzione) copy is filtered by c.stato NOT IN (...)'); + $check(in_array($cFree, $availableIds, true), 'B4 a plain free copy is offered'); + $check(count($availableIds) === 2, 'B5 exactly the disjoint + free copies are returned'); + + /* ================================================================== + * C (point 3) — stale in_corso overdue-by-date is open-ended; a second + * loan on the SAME copy is rejected, a DIFFERENT copy is allowed. + * ================================================================ */ + [$bc, $ccopies] = $mkBook(2, 'prestato'); + [$copyStale, $copyOther] = $ccopies; + $borrower = $mkUser(); + + // Overdue by DATE (data_scadenza in the past) but still in_corso — the cron + // has not flipped it to in_ritardo yet. + $staleLoan = $loan($bc, $copyStale, $borrower, $d(-30), $d(-5), 'in_corso', 1); + $check($staleLoan > 0, 'C1 a stale in_corso loan overdue-by-date inserts'); + + $rejected = false; + try { + // Future non-overlapping-by-date window on the SAME copy: must still be + // rejected because the stale loan is treated as open-ended. + $loan($bc, $copyStale, $mkUser(), $d(20), $d(30), 'in_corso', 1); + } catch (mysqli_sql_exception $e) { + $rejected = true; + } + $sameCopyCount = (int) $db->query("SELECT COUNT(*) FROM prestiti WHERE copia_id={$copyStale}")->fetch_row()[0]; + $check($rejected && $sameCopyCount === 1, 'C2 a future loan on the SAME copy is rejected — stale overdue is open-ended'); + + $otherCopyLoan = $loan($bc, $copyOther, $borrower, $d(20), $d(30), 'in_corso', 1); + $check($otherCopyLoan > 0, 'C3 a DIFFERENT copy for the same borrower is allowed (per-copy trigger scope)'); + + /* ================================================================== + * D (point 4) — multiplicity ON: distinct copies of one title for one + * borrower coexist; the same physical copy never hosts two open loans. + * ================================================================ */ + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + ConfigStore::clearCache(); + [$bm, $mcopies] = $mkBook(2); + [$mA, $mB] = $mcopies; + $mUser = $mkUser(); + + $lA = $loan($bm, $mA, $mUser, $d(0), $d(7), 'in_corso', 1); + $check($lA > 0, 'D1 multiplicity ON: first physical copy loan inserts'); + $lB = $loan($bm, $mB, $mUser, $d(0), $d(7), 'in_corso', 1); + $distinct = (int) $db->query("SELECT COUNT(DISTINCT copia_id) FROM prestiti WHERE libro_id={$bm} AND utente_id={$mUser} AND attivo=1")->fetch_row()[0]; + $check($lB > 0 && $distinct === 2, 'D2 a second DISTINCT copy of the same title inserts (2 distinct copies held)'); + + $sameRejected = false; + try { + $loan($bm, $mA, $mUser, $d(0), $d(7), 'in_corso', 1); + } catch (mysqli_sql_exception $e) { + $sameRejected = true; + } + $check($sameRejected, 'D3 a second overlapping loan on the SAME copy is rejected by the trigger even while ON'); + + $policyOn = new LoanMultiplicityPolicy($db); + $check( + !$policyOn->hasBlockingLoan($bm, $mUser, true), + 'D4 LoanMultiplicityPolicy ON: an existing copy-bound sibling does not block a further copy-bound operation' + ); + + /* ================================================================== + * E (point 5) — the ready-for-pickup claim UPDATE is atomic. + * ================================================================ */ + [$bp, $pcopies] = $mkBook(1); + $pUser = $mkUser(); + $pickupLoan = $loan($bp, $pcopies[0], $pUser, $d(0), $d(7), 'da_ritirare', 1); + $db->query("UPDATE prestiti SET pickup_notification_sent=0, pickup_notification_claim_token=NULL, pickup_notification_last_attempt_at=NULL WHERE id={$pickupLoan}"); + + $claim = static function (int $loanId, int $userId, string $token) use ($db): int { + $w = PickupNotificationSchema::claimLeaseWindow(); + $stmt = $db->prepare( + "UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ? AND utente_id = ? + AND attivo = 1 AND stato = 'da_ritirare' + AND ( + pickup_notification_sent IS NULL + OR pickup_notification_sent = 0 + OR ( + pickup_notification_sent = 1 + AND pickup_notification_claim_token IS NOT NULL + AND pickup_notification_last_attempt_at < ? + ) + )" + ); + $attemptedAt = $w['attemptedAt']; + $staleBefore = $w['staleBefore']; + $stmt->bind_param('ssiis', $token, $attemptedAt, $loanId, $userId, $staleBefore); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + return $affected; + }; + + $tok1 = bin2hex(random_bytes(16)); + $tok2 = bin2hex(random_bytes(16)); + $check($claim($pickupLoan, $pUser, $tok1) === 1, 'E1 first claim wins (affected_rows == 1)'); + $check($claim($pickupLoan, $pUser, $tok2) === 0, 'E2 a concurrent second claim loses while the lease is live (affected_rows == 0)'); + + // Finalize the announcement: the sender clears its own token; sent stays 1. + $fin = $db->prepare("UPDATE prestiti SET pickup_notification_claim_token = NULL WHERE id = ? AND pickup_notification_claim_token = ?"); + $fin->bind_param('is', $pickupLoan, $tok1); + $fin->execute(); + $fin->close(); + $check($claim($pickupLoan, $pUser, bin2hex(random_bytes(16))) === 0, 'E3 a finalized claim (sent=1, token cleared) is never re-selected'); + + // Fair retry: a stale lease (older than the window) is re-claimable. + $w = PickupNotificationSchema::claimLeaseWindow(); + $staleTs = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->modify('-2000 seconds')->format('Y-m-d H:i:s'); + $staleTok = bin2hex(random_bytes(16)); + $set = $db->prepare("UPDATE prestiti SET pickup_notification_sent=1, pickup_notification_claim_token=?, pickup_notification_last_attempt_at=? WHERE id=?"); + $set->bind_param('ssi', $staleTok, $staleTs, $pickupLoan); + $set->execute(); + $set->close(); + $check($claim($pickupLoan, $pUser, bin2hex(random_bytes(16))) === 1, 'E4 an expired/stale claim lease is fairly re-claimable (affected_rows == 1)'); + + /* ================================================================== + * F (point 6) — NCIP checkout stays strict even with multiplicity ON. + * Reproduces the plugin's own duplicate predicate (NcipServerPlugin + * atomicCheckout) verbatim against seeded rows. + * ================================================================ */ + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + ConfigStore::clearCache(); + + [$bn, $ncopies] = $mkBook(2); + $nUser = $mkUser(); + $loan($bn, $ncopies[0], $nUser, $d(0), $d(7), 'in_corso', 1); // active copy-bound loan + + $ncipDuplicate = static function (int $bookId, int $userId) use ($db): bool { + $stmt = $db->prepare( + "SELECT id FROM prestiti + WHERE libro_id = ? AND utente_id = ? + AND ((attivo = 0 AND stato = 'pendente') + OR (attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo'))) + LIMIT 1" + ); + $stmt->bind_param('ii', $bookId, $userId); + $stmt->execute(); + $hit = (bool) $stmt->get_result()->fetch_row(); + $stmt->close(); + return $hit; + }; + + $check($ncipDuplicate($bn, $nUser), 'F1 NCIP duplicate predicate rejects a second borrower/title checkout even while multiplicity is ON'); + $relaxed = new LoanMultiplicityPolicy($db); + $check( + !$relaxed->hasBlockingLoan($bn, $nUser, true), + 'F2 the relaxed policy WOULD allow another distinct copy here — NCIP deliberately does not adopt it' + ); + + // The pendente-copyless branch of the predicate is also strict. + [$bn2, ] = $mkBook(1); + $nUser2 = $mkUser(); + $loan($bn2, null, $nUser2, $d(0), $d(7), 'pendente', 0); + $check($ncipDuplicate($bn2, $nUser2), 'F3 NCIP duplicate predicate also catches a pending/copyless request'); +} finally { + try { $db->rollback(); } catch (Throwable $e) {} + try { $cleanup(); } catch (Throwable $e) { $failed++; fwrite(STDERR, "CLEANUP ERROR: {$e->getMessage()}\n"); } + try { + $restoreSetting('allow_multiple_loans_same_book', $origMultiplicity); + $restoreSetting('max_active_loans_per_user', $origMaxLoans); + } catch (Throwable $e) { + $failed++; + fwrite(STDERR, "SETTING RESTORE ERROR: {$e->getMessage()}\n"); + } + $_SESSION = $sessionBefore; + $db->close(); +} + +echo "\n{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/migration-0.7.36-rc.1.unit.php b/tests/migration-0.7.36-rc.1.unit.php index 92808212d..e97c3b590 100644 --- a/tests/migration-0.7.36-rc.1.unit.php +++ b/tests/migration-0.7.36-rc.1.unit.php @@ -222,7 +222,7 @@ function migLoadEnv(string $path): array $updater = (string) file_get_contents($root . '/app/Support/Updater.php'); $check(strpos($updater, 'ContributorBackfill::run') !== false, "migration runner completes the backfill before returning success"); $upgradeSmoke = (string) file_get_contents($root . '/.github/workflows/ci-upgrade-smoke.yml'); -$check(strpos($upgradeSmoke, 'runMigrations($target, $target)') !== false +$check(strpos($upgradeSmoke, '$updater->runMigrations($from, $target)') !== false && strpos($upgradeSmoke, "setting_key='contributors_backfilled'") !== false && strpos($upgradeSmoke, "source='legacy-backfill'") !== false, 'upgrade smoke executes and verifies the runtime backfill through Updater'); diff --git a/tests/migration-0.7.61-rc.1.unit.php b/tests/migration-0.7.61-rc.1.unit.php index b2c4b6c91..2fa84a1ef 100644 --- a/tests/migration-0.7.61-rc.1.unit.php +++ b/tests/migration-0.7.61-rc.1.unit.php @@ -49,6 +49,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — migration test is mandatory: {$e->getMessage()}\n"); exit(1); diff --git a/tests/migration-0.7.62-rc.1.unit.php b/tests/migration-0.7.62-rc.1.unit.php index c42a9d4ad..4b31ce573 100644 --- a/tests/migration-0.7.62-rc.1.unit.php +++ b/tests/migration-0.7.62-rc.1.unit.php @@ -35,6 +35,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — migration test is mandatory: {$e->getMessage()}\n"); exit(1); diff --git a/tests/migration-0.7.63-rc.1.unit.php b/tests/migration-0.7.63-rc.1.unit.php index fba36781d..fa2c43093 100644 --- a/tests/migration-0.7.63-rc.1.unit.php +++ b/tests/migration-0.7.63-rc.1.unit.php @@ -34,6 +34,11 @@ ? new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); diff --git a/tests/migration-0.7.64.unit.php b/tests/migration-0.7.64.unit.php new file mode 100644 index 000000000..1bd063a6a --- /dev/null +++ b/tests/migration-0.7.64.unit.php @@ -0,0 +1,206 @@ +set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — migration test is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +// Random suffix: concurrent runs (or a leftover table from an aborted one) +// must never collide with — or drop — anything but this run's own sandbox. +$sandboxSuffix = bin2hex(random_bytes(6)); +$sandboxTable = 'zz_mig_prestiti_0764_' . $sandboxSuffix; +$sandboxTrigger = 'zz_mig_trg_0764_' . $sandboxSuffix; +$sandboxMarker = 'pickup_notification_backfill_0_7_64_' . $sandboxSuffix; +$migration = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.7.64-rc.1.sql'); +// The migration references the table both backticked (ALTER) and as a quoted +// string (INFORMATION_SCHEMA lookup): rewrite both onto the sandbox. +$sandboxMigration = static fn(string $sql): string => str_replace( + ['`prestiti`', "'prestiti'", 'pickup_notification_backfill_0_7_64'], + ["`{$sandboxTable}`", "'{$sandboxTable}'", $sandboxMarker], + $sql +); +$runMigration = static function () use ($db, $migration, $sandboxMigration): void { + $withoutComments = preg_replace('/^--.*$/m', '', $migration) ?? $migration; + foreach (array_filter(array_map('trim', preg_split('/;\s*\n/', $sandboxMigration($withoutComments)))) as $statement) { + $db->query($statement); + } +}; +$cleanup = static function () use ($db, $sandboxTable, $sandboxMarker): void { + $db->query("DROP TABLE IF EXISTS `{$sandboxTable}`"); + $stmt = $db->prepare("DELETE FROM system_settings WHERE category = 'migrations' AND setting_key = ?"); + $stmt->bind_param('s', $sandboxMarker); + $stmt->execute(); + $stmt->close(); +}; + +echo "A. Real migration on a sandbox prestiti table\n"; +try { + $cleanup(); + // Minimal legacy prestiti shape: notification columns may all be absent. + // In particular warning_sent/overdue_notification_sent are deliberately + // absent: the migration must not depend on a runtime self-heal having run + // first or on an AFTER clause naming either legacy column. + $db->query("CREATE TABLE `{$sandboxTable}` ( + `id` INT NOT NULL AUTO_INCREMENT, + `stato` VARCHAR(20) NOT NULL DEFAULT 'in_corso', + `attivo` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + // Seed a loan BEFORE the migration so the assertions below cover the + // safe historical default applied to pre-existing rows, not only freshly + // inserted ones. + $db->query("INSERT INTO `{$sandboxTable}` (`stato`) VALUES ('da_ritirare'), ('in_corso')"); + + // A legacy circulation trigger may reject every UPDATE on an inconsistent + // loan even when only notification metadata changes. The migration must + // therefore initialize history through DDL defaults, never row DML. + $db->query("CREATE TRIGGER `{$sandboxTrigger}` BEFORE UPDATE ON `{$sandboxTable}` + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'legacy trigger rejected UPDATE'"); + + $runMigration(); + $db->query("DROP TRIGGER `{$sandboxTrigger}`"); + + $column = $db->query( + "SELECT DATA_TYPE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$sandboxTable}' + AND COLUMN_NAME = 'pickup_notification_sent'" + )->fetch_assoc(); + $check($column !== null && $column['DATA_TYPE'] === 'tinyint', 'migration adds the pickup-notification flag'); + $check( + $column !== null + && $column['COLUMN_DEFAULT'] !== null + && (int) $column['COLUMN_DEFAULT'] === 0, + 'flag defaults to zero (never announced yet)' + ); + $protocolColumns = (int) $db->query( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$sandboxTable}' + AND COLUMN_NAME IN ('pickup_notification_claim_token', 'pickup_notification_last_attempt_at')" + )->fetch_row()[0]; + $check($protocolColumns === 2, 'migration adds claim ownership and fair-retry metadata'); + + $rows = $db->query("SELECT stato, pickup_notification_sent FROM `{$sandboxTable}` ORDER BY id")->fetch_all(MYSQLI_ASSOC); + $check( + count($rows) === 2 + && $rows[0]['stato'] === 'da_ritirare' + && (int) $rows[0]['pickup_notification_sent'] === 1, + 'pre-existing ready-for-pickup loans are treated as already announced' + ); + $check( + count($rows) === 2 + && $rows[1]['stato'] === 'in_corso' + && (int) $rows[1]['pickup_notification_sent'] === 1, + 'all historical rows are safely initialized without firing legacy UPDATE triggers' + ); + + // A row created after the migration must retain DEFAULT 0 so the new + // claim/retry pipeline announces it normally. + $db->query("INSERT INTO `{$sandboxTable}` (`stato`) VALUES ('da_ritirare')"); + $newRow = $db->query("SELECT pickup_notification_sent FROM `{$sandboxTable}` ORDER BY id DESC LIMIT 1")->fetch_assoc(); + $check((int) $newRow['pickup_notification_sent'] === 0, 'new ready-for-pickup loans start unannounced'); + + // Simulate a claimed announcement, then re-run: idempotency must not reset it. + $db->query("UPDATE `{$sandboxTable}` SET pickup_notification_sent = 1 WHERE stato = 'in_corso'"); + $runMigration(); + $row = $db->query("SELECT pickup_notification_sent FROM `{$sandboxTable}` WHERE stato = 'in_corso'")->fetch_assoc(); + $check( + (int) $row['pickup_notification_sent'] === 1, + 'migration is idempotent and preserves the claimed flag' + ); + $marker = $db->prepare("SELECT setting_value FROM system_settings WHERE category = 'migrations' AND setting_key = ?"); + $marker->bind_param('s', $sandboxMarker); + $marker->execute(); + $markerValue = (string) ($marker->get_result()->fetch_row()[0] ?? ''); + $marker->close(); + $check($markerValue === 'done', 'resumable backfill marker is completed'); +} catch (Throwable $e) { + $failed++; + echo " FAIL exception: {$e->getMessage()}\n"; +} finally { + $cleanup(); + $db->close(); +} + +echo "B. Release wiring\n"; +$schema = (string) file_get_contents($root . '/installer/database/schema.sql'); +$check( + str_contains($schema, '`pickup_notification_sent` tinyint(1) DEFAULT'), + 'fresh-install schema includes the pickup-notification flag' +); +$check( + str_contains($schema, '`pickup_notification_claim_token` char(32)') + && str_contains($schema, '`pickup_notification_last_attempt_at` datetime'), + 'fresh-install schema includes claim ownership and fair-retry metadata' +); + +// Pre-migration installs are covered by the runtime self-healing paths — the +// same belt-and-braces used for warning_sent/overdue_notification_sent. +$notificationService = (string) file_get_contents($root . '/app/Support/NotificationService.php'); +$pickupSchema = (string) file_get_contents($root . '/app/Support/PickupNotificationSchema.php'); +$check( + str_contains($notificationService, 'PickupNotificationSchema::ensure($this->db)') + && str_contains($pickupSchema, 'ADD COLUMN pickup_notification_sent TINYINT(1) DEFAULT {$historicalDefault}'), + 'NotificationService delegates to the resumable runtime schema helper' +); +$maintenanceService = (string) file_get_contents($root . '/app/Support/MaintenanceService.php'); +$check( + str_contains($maintenanceService, 'PickupNotificationSchema::ensure($this->db)'), + 'MaintenanceService ensures the schema before its lifecycle UPDATEs' +); + +// Exercise the exact production predicate called by Updater::runMigrations(). +// The file is named -rc.1 so it also runs when 0.7.64 ships from a prerelease +// (version_compare('0.7.64','0.7.64-rc.1','<=') would be false and skip it). +$mig = '0.7.64-rc.1'; +$check(Updater::shouldRunMigration($mig, '0.7.63', '0.7.64'), 'Updater runs it on 0.7.63 -> 0.7.64'); +$check(Updater::shouldRunMigration($mig, '0.7.62', '0.7.64'), 'Updater runs it on 0.7.62 -> 0.7.64 (skipped release)'); +$check(Updater::shouldRunMigration($mig, '0.7.63', '0.7.64-rc.1'), 'Updater runs it on 0.7.63 -> 0.7.64-rc.1 (RC upgrade)'); +$check(!Updater::shouldRunMigration($mig, '0.7.64', '0.7.65'), 'Updater does NOT re-run it once 0.7.64 is installed'); + +echo "\n{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/multiple-copy-loans-238.spec.js b/tests/multiple-copy-loans-238.spec.js new file mode 100644 index 000000000..26e29087d --- /dev/null +++ b/tests/multiple-copy-loans-238.spec.js @@ -0,0 +1,373 @@ +// @ts-check +// +// E2E — Multiple physical copies per borrower (#238, PR feature/multiple-copy-loans) +// +// Exercises the opt-in `loans.allow_multiple_loans_same_book` setting through +// the REAL admin desk flow (browser + Apache + the loan controller + the +// per-copy DB triggers), not just the backend policy. Five scenarios: +// 1. the settings toggle persists from the UI +// 2. ON → one borrower may take two DISTINCT copies of the same title +// 3. ON → the SAME copy cannot be lent twice to the same borrower +// 4. OFF → the historical borrower/title uniqueness rule still rejects a +// second copy (backward compatibility) +// 5. ON → "Salva e registra un'altra copia" keeps the title selected and +// lets the operator scan the next copy (desk batch workflow) +// +// Self-provisions its own admin, borrowers, book and copies (tokenised), and +// cleans everything up in afterAll. Requires a real installed DB — the runner +// (/tmp/run-e2e.sh) supplies E2E_DB_* and the base URL. + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); + +const BASE = process.env.E2E_BASE_URL || process.env.APP_URL || 'http://localhost:8081'; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip( + !DB_USER || !DB_NAME, + 'E2E DB credentials not configured (set E2E_DB_USER, E2E_DB_NAME via /tmp/run-e2e.sh)', +); + +// Serial: the tests share one book + copy pool and reset it between runs. +test.describe.configure({ mode: 'serial' }); + +/** + * Run a MySQL statement and return trimmed stdout. + * Connection precedence: TCP (-h/-P) → Unix socket (-S) → defaults. + * Password is passed via MYSQL_PWD so it never appears in argv. + */ +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', + timeout: 15000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +/** Single-quote + escape a value for inlining as a SQL string literal. */ +const S = (v) => "'" + String(v).replace(/'/g, "''") + "'"; + +/** Compute a bcrypt hash the app's password_verify() will accept. */ +function phpPasswordHash(pw) { + return execFileSync('php', ['-r', `echo password_hash(${JSON.stringify(pw)}, PASSWORD_DEFAULT);`], { + encoding: 'utf-8', + timeout: 15000, + }).trim(); +} + +const RUN = crypto.randomBytes(4).toString('hex'); +const DOMAIN = '@238mc.test.local'; +const ADMIN_EMAIL = `mc-admin-${RUN}${DOMAIN}`; +const ADMIN_PASS = 'Mc238Pass!'; + +/** @type {{adminId:number, bookId:number, copies:Record, origSetting:{exists:boolean, valueHex:string|null}}} */ +const fx = { adminId: 0, bookId: 0, copies: {}, origSetting: { exists: false, valueHex: null } }; + +let borrowerSeq = 0; + +/** Provision a fresh standard borrower with a searchable, unique surname. */ +function makeBorrower(tag) { + borrowerSeq++; + const surname = `Mc${tag}${RUN}`; + const email = `mc-b-${tag}-${RUN}${DOMAIN}`; + const card = `MC238${RUN.toUpperCase()}${borrowerSeq}`; + const hash = phpPasswordHash('Borrow238!'); + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S(card)}, 'Borrower', ${S(surname)}, ${S(email)}, ${S(hash)}, 'standard', 'attivo', 1, 1, 'it_IT')`, + ); + const id = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(email)} LIMIT 1`)); + return { id, surname, email }; +} + +/** Flip the opt-in setting directly in the DB (ConfigStore re-reads per request). */ +function setMultiplicity(on) { + dbQuery( + `INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('loans', 'allow_multiple_loans_same_book', ${on ? "'1'" : "'0'"}) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, + ); +} + +/** Wipe the test book's loans and free its copies so each test starts clean. */ +function resetLoanState() { + dbQuery(`DELETE FROM admin_notifications WHERE type='general' AND related_id IN (SELECT id FROM prestiti WHERE libro_id=${fx.bookId})`); + dbQuery(`DELETE FROM prestiti WHERE libro_id=${fx.bookId}`); + dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${fx.bookId}`); + dbQuery(`UPDATE libri SET copie_disponibili=copie_totali WHERE id=${fx.bookId}`); +} + +/** Reproduce full-test's resilient autocomplete picker. */ +async function fillAutocomplete(page, inputSelector, suggestSelector, query, apiUrlFragment) { + for (let attempt = 1; attempt <= 3; attempt++) { + await page.fill(inputSelector, ''); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes(apiUrlFragment) && resp.status() === 200, + { timeout: 15000 }, + ); + await page.locator(inputSelector).pressSequentially(query, { delay: 50 }); + await responsePromise; + const suggestion = page.locator(`${suggestSelector} .suggestion-item`).first(); + if (await suggestion.isVisible({ timeout: 3000 }).catch(() => false)) { + await suggestion.click(); + return; + } + if (attempt < 3) { + await page.fill(inputSelector, ''); + await page.waitForTimeout(300); + } + } + await page.locator(`${suggestSelector} .suggestion-item`).first().click({ timeout: 5000 }); +} + +async function loginAdmin(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('form button[type="submit"], button[type="submit"]').first().click(); + await page.waitForURL((url) => !url.toString().includes('/accedi'), { timeout: 15000 }); +} + +/** Scan a copy code on the create form: the resolver auto-identifies the book. */ +async function scanCopy(page, inv) { + const resolved = page.waitForResponse( + (r) => r.url().includes('/admin/copies/by-code') && r.status() === 200, + { timeout: 15000 }, + ); + await page.fill('#copy_code', ''); + await page.locator('#copy_code').pressSequentially(inv, { delay: 30 }); + await resolved; + await page.waitForTimeout(300); // let the resolver populate #libro_id +} + +/** Wait for the create form to settle after a submit (success, batch, or error). */ +async function waitLoanOutcome(page) { + await page.waitForFunction( + () => { + const u = new URL(window.location.href); + return ( + u.searchParams.has('created') || + u.searchParams.has('error') || + (u.pathname.startsWith('/admin/loans') && !u.pathname.endsWith('/create')) + ); + }, + null, + { timeout: 30000 }, + ); + return page.url(); +} + +/** Full desk create: pick borrower, scan a copy, submit. Returns the result URL. */ +async function deskCreate(page, borrower, inv, { saveAndNew = false } = {}) { + await page.goto(`${BASE}/admin/loans/create`); + await page.waitForLoadState('domcontentloaded'); + await fillAutocomplete(page, '#utente_search', '#utente_suggest', borrower.surname, '/api/search/utenti'); + await scanCopy(page, inv); + const btn = saveAndNew + ? 'button[name="save_and_new"]' + : 'button[type="submit"]:not([name="save_and_new"])'; + await page.locator(btn).click(); + return waitLoanOutcome(page); +} + +/** Distinct active copy_ids currently committed by a borrower for the book. */ +function activeCopyIds(userId) { + const out = dbQuery( + `SELECT copia_id FROM prestiti + WHERE utente_id=${userId} AND libro_id=${fx.bookId} AND attivo=1 + AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo') + ORDER BY copia_id`, + ); + return out.split('\n').map((s) => s.trim()).filter(Boolean); +} + +test.beforeAll(() => { + // Preserve the current setting so afterAll can restore it EXACTLY. + // dbQuery() trims stdout, so an existing empty-string value is + // indistinguishable from a missing row when read directly: detect row + // existence separately and serialize the value losslessly via HEX() + // (NULL stays NULL — mysql -N -B prints it as the literal "NULL"). + const exists = dbQuery( + `SELECT COUNT(*) FROM system_settings WHERE category='loans' AND setting_key='allow_multiple_loans_same_book'`, + ) !== '0'; + const hex = exists + ? dbQuery( + `SELECT HEX(setting_value) FROM system_settings WHERE category='loans' AND setting_key='allow_multiple_loans_same_book' LIMIT 1`, + ) + : null; + fx.origSetting = { exists, valueHex: hex }; + + // Admin for browser login. + const adminHash = phpPasswordHash(ADMIN_PASS); + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S('MCADM' + RUN.toUpperCase())}, 'Admin', ${S('Mc' + RUN)}, ${S(ADMIN_EMAIL)}, ${S(adminHash)}, 'admin', 'attivo', 1, 1, 'it_IT')`, + ); + fx.adminId = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(ADMIN_EMAIL)} LIMIT 1`)); + + // Book with three copies. + dbQuery(`INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (${S('ZZ238MC ' + RUN + ' Multi Copy Book')}, 3, 3)`); + fx.bookId = Number(dbQuery(`SELECT id FROM libri WHERE titolo=${S('ZZ238MC ' + RUN + ' Multi Copy Book')} ORDER BY id DESC LIMIT 1`)); + + for (const tag of ['A', 'B', 'C']) { + const inv = `MC238-${RUN}-${tag}`; + dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.bookId}, ${S(inv)}, 'disponibile')`); + const id = Number(dbQuery(`SELECT id FROM copie WHERE numero_inventario=${S(inv)} LIMIT 1`)); + fx.copies[tag] = { id, inv }; + } +}); + +test.afterAll(() => { + if (fx.bookId) { + dbQuery(`DELETE FROM admin_notifications WHERE type='general' AND related_id IN (SELECT id FROM prestiti WHERE libro_id=${fx.bookId})`); + dbQuery(`DELETE FROM prestiti WHERE libro_id=${fx.bookId}`); + dbQuery(`DELETE FROM copie WHERE libro_id=${fx.bookId}`); + dbQuery(`DELETE FROM libri WHERE id=${fx.bookId}`); + } + dbQuery(`DELETE FROM user_sessions WHERE utente_id IN (SELECT id FROM utenti WHERE email LIKE ${S('%' + RUN + DOMAIN)})`); + dbQuery(`DELETE FROM utenti WHERE email LIKE ${S('%' + RUN + DOMAIN)}`); + + // Restore the original setting exactly (byte-for-byte via UNHEX; a HEX() + // of NULL surfaces as the literal "NULL" and is restored as SQL NULL). + if (fx.origSetting.exists) { + const hex = fx.origSetting.valueHex; + const valueExpr = hex === 'NULL' + ? 'NULL' + : `UNHEX('${String(hex).replace(/[^0-9A-Fa-f]/g, '')}')`; + dbQuery( + `INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('loans', 'allow_multiple_loans_same_book', ${valueExpr}) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, + ); + } else { + dbQuery(`DELETE FROM system_settings WHERE category='loans' AND setting_key='allow_multiple_loans_same_book'`); + } +}); + +test.beforeEach(() => { + resetLoanState(); +}); + +test('1. il toggle "più copie" persiste dalla UI', async ({ page }) => { + setMultiplicity(false); + await loginAdmin(page); + + await page.goto(`${BASE}/admin/settings?tab=loans`); + await page.locator('[data-settings-tab="loans"]').click(); + + // The real checkbox is `sr-only`; the styled toggle-bg div overlays it, so + // force the state change instead of a pointer click. + const toggle = page.locator('#allow_multiple_loans_same_book'); + await expect(toggle).not.toBeChecked(); + await toggle.check({ force: true }); + + await page.locator('section[data-settings-panel="loans"] button[type="submit"]').first().click(); + await page.waitForLoadState('domcontentloaded'); + + // The controller persisted it. + expect( + dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='allow_multiple_loans_same_book'`), + ).toBe('1'); + + // And the reloaded UI reflects it. + await page.goto(`${BASE}/admin/settings?tab=loans`); + await page.locator('[data-settings-tab="loans"]').click(); + await expect(page.locator('#allow_multiple_loans_same_book')).toBeChecked(); +}); + +test('2. ON → lo stesso utente ottiene due copie fisiche distinte', async ({ page }) => { + setMultiplicity(true); + const borrower = makeBorrower('T2'); + await loginAdmin(page); + + const url1 = await deskCreate(page, borrower, fx.copies.A.inv); + expect(url1).not.toContain('error='); + + const url2 = await deskCreate(page, borrower, fx.copies.B.inv); + expect(url2).not.toContain('error='); + + const copies = activeCopyIds(borrower.id); + expect(copies.length).toBe(2); + expect(new Set(copies).size).toBe(2); // distinct physical items + expect(copies).toEqual(expect.arrayContaining([String(fx.copies.A.id), String(fx.copies.B.id)])); +}); + +test('3. ON → la stessa copia non può essere prestata due volte allo stesso utente', async ({ page }) => { + setMultiplicity(true); + const borrower = makeBorrower('T3'); + await loginAdmin(page); + + const url1 = await deskCreate(page, borrower, fx.copies.A.inv); + expect(url1).not.toContain('error='); + + // Scanning the very same copy again must be refused. + const url2 = await deskCreate(page, borrower, fx.copies.A.inv); + expect(url2).toContain('error='); + + const cnt = Number( + dbQuery(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${borrower.id} AND libro_id=${fx.bookId} AND copia_id=${fx.copies.A.id} AND attivo=1`), + ); + expect(cnt).toBe(1); +}); + +test('4. OFF → un secondo prestito dello stesso titolo è rifiutato (comportamento storico)', async ({ page }) => { + setMultiplicity(false); + const borrower = makeBorrower('T4'); + await loginAdmin(page); + + const url1 = await deskCreate(page, borrower, fx.copies.A.inv); + expect(url1).not.toContain('error='); + + // Different copy, but strict mode keeps the borrower/title rule. + const url2 = await deskCreate(page, borrower, fx.copies.B.inv); + expect(url2).toContain('error=duplicate_reservation'); + + const cnt = Number( + dbQuery(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${borrower.id} AND libro_id=${fx.bookId} AND attivo=1`), + ); + expect(cnt).toBe(1); +}); + +test('5. ON → "Salva e registra un\'altra copia" mantiene il titolo e permette la scansione successiva', async ({ page }) => { + setMultiplicity(true); + const borrower = makeBorrower('T5'); + await loginAdmin(page); + + await page.goto(`${BASE}/admin/loans/create`); + await page.waitForLoadState('domcontentloaded'); + await fillAutocomplete(page, '#utente_search', '#utente_suggest', borrower.surname, '/api/search/utenti'); + await scanCopy(page, fx.copies.A.inv); + await page.locator('button[name="save_and_new"]').click(); + + // Batch mode: back on the create form with the title + borrower retained. + await page.waitForURL( + (url) => url.pathname.endsWith('/admin/loans/create') && new URL(url.toString()).searchParams.get('created') === '1', + { timeout: 30000 }, + ); + await expect(page.locator('#libro_id')).toHaveValue(String(fx.bookId)); // retained (the feature) + await expect(page.locator('#utente_id')).toHaveValue(String(borrower.id)); + await expect(page.locator('#copy_code')).toHaveValue(''); + + // Scan the next copy and finish the batch. + await scanCopy(page, fx.copies.B.inv); + await page.locator('button[type="submit"]:not([name="save_and_new"])').click(); + await waitLoanOutcome(page); + + const copies = activeCopyIds(borrower.id); + expect(copies.length).toBe(2); + expect(new Set(copies).size).toBe(2); +}); diff --git a/tests/multiple-copy-loans-238.unit.php b/tests/multiple-copy-loans-238.unit.php new file mode 100644 index 000000000..a18c99a7c --- /dev/null +++ b/tests/multiple-copy-loans-238.unit.php @@ -0,0 +1,1229 @@ +set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +$testNo = 0; +$failed = 0; +$check = static function (bool $condition, string $description) use (&$testNo, &$failed): void { + $testNo++; + printf("[%02d] %s: %s\n", $testNo, $condition ? 'PASS' : 'FAIL', $description); + if (!$condition) { + $failed++; + } +}; + +$run = bin2hex(random_bytes(6)); +$titlePrefix = "ZZ_238MULTI_{$run}_"; +$emailDomain = '@238multi.test.local'; +$today = DateHelper::today(); +$due = (new DateTimeImmutable($today))->modify('+14 days')->format('Y-m-d'); +$sessionBefore = $_SESSION ?? []; + +/** @return array{exists: bool, value: ?string} */ +$captureSetting = static function (string $key) use ($db): array { + $stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = ? LIMIT 1"); + $stmt->bind_param('s', $key); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + return [ + 'exists' => $row !== null, + 'value' => $row !== null && $row['setting_value'] !== null ? (string) $row['setting_value'] : null, + ]; +}; + +$restoreSetting = static function (string $key, array $original) use ($db): void { + if (!$original['exists']) { + $stmt = $db->prepare("DELETE FROM system_settings WHERE category = 'loans' AND setting_key = ?"); + $stmt->bind_param('s', $key); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); + return; + } + + $value = $original['value']; + $stmt = $db->prepare( + "INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('loans', ?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)" + ); + $stmt->bind_param('ss', $key, $value); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); +}; + +$originalMultiplicity = $captureSetting('allow_multiple_loans_same_book'); +$originalMaxLoans = $captureSetting('max_active_loans_per_user'); + +$userSequence = 0; +$makeUser = static function (string $role = 'standard') use ($db, $run, $emailDomain, &$userSequence): int { + $userSequence++; + $card = 'Z238' . strtoupper(substr($run, 0, 8)) . $userSequence; + $email = "u{$userSequence}-{$run}{$emailDomain}"; + $surname = "ZZ238Multi {$userSequence}"; + $stmt = $db->prepare( + "INSERT INTO utenti + (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'Test', ?, ?, 'x', 'attivo', ?, 1)" + ); + $stmt->bind_param('ssss', $card, $surname, $email, $role); + $stmt->execute(); + $stmt->close(); + + return (int) $db->insert_id; +}; + +$bookSequence = 0; +/** @return array{0: int, 1: list, 2: list} */ +$makeBook = static function (int $copies) use ($db, $titlePrefix, &$bookSequence): array { + $bookSequence++; + $title = $titlePrefix . $bookSequence; + $stmt = $db->prepare( + 'INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW())' + ); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + + $copyIds = []; + $copyCodes = []; + for ($copyNo = 1; $copyNo <= $copies; $copyNo++) { + $code = "ZZ238M-{$bookId}-C{$copyNo}"; + $stmt = $db->prepare( + "INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')" + ); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + $copyCodes[] = $code; + } + + return [$bookId, $copyIds, $copyCodes]; +}; + +$makeLoan = static function ( + int $bookId, + ?int $copyId, + int $userId, + string $state, + int $active, + ?string $startDate = null, + ?string $endDate = null, + string $origin = 'diretto' +) use ($db, $today, $due): int { + $loanStart = $startDate ?? $today; + $loanEnd = $endDate ?? $due; + $stmt = $db->prepare( + "INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param('iiissssi', $bookId, $copyId, $userId, $loanStart, $loanEnd, $state, $origin, $active); + $stmt->execute(); + $stmt->close(); + + return (int) $db->insert_id; +}; + +$makeActiveReservation = static function (int $bookId, int $userId) use ($db, $today, $due): int { + $stmt = $db->prepare( + "INSERT INTO prenotazioni + (libro_id, utente_id, data_inizio_richiesta, data_fine_richiesta, + data_scadenza_prenotazione, stato, queue_position, notifica_inviata) + VALUES (?, ?, ?, ?, ?, 'attiva', 1, 0)" + ); + $stmt->bind_param('iisss', $bookId, $userId, $today, $due, $due); + $stmt->execute(); + $stmt->close(); + + return (int) $db->insert_id; +}; + +$loanCount = static function (int $bookId, int $userId) use ($db): int { + $stmt = $db->prepare('SELECT COUNT(*) FROM prestiti WHERE libro_id = ? AND utente_id = ?'); + $stmt->bind_param('ii', $bookId, $userId); + $stmt->execute(); + $count = (int) $stmt->get_result()->fetch_row()[0]; + $stmt->close(); + return $count; +}; + +$cleanup = static function () use ($db, $titlePrefix, $run, $emailDomain): void { + $titleLike = $db->real_escape_string($titlePrefix) . '%'; + $db->query("DELETE n FROM admin_notifications n JOIN prestiti p ON p.id = n.related_id JOIN libri l ON l.id = p.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE r FROM prenotazioni r JOIN libri l ON l.id = r.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE p FROM prestiti p JOIN libri l ON l.id = p.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE c FROM copie c JOIN libri l ON l.id = c.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE FROM libri WHERE titolo LIKE '{$titleLike}'"); + $emailLike = $db->real_escape_string("u%-{$run}{$emailDomain}"); + $db->query("DELETE FROM utenti WHERE email LIKE '{$emailLike}'"); +}; + +$callStore = static function ( + int $adminId, + int $userId, + int $bookId, + string $copyCode, + bool $saveAndNew = false, + ?string $startDate = null, + ?string $endDate = null, + ?string $submissionToken = null +) use ($db, $today, $due) { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $adminId]; + $body = [ + 'loan_submission_token' => $submissionToken ?? \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => (string) $userId, + 'libro_id' => (string) $bookId, + 'copy_code' => $copyCode, + 'data_prestito' => $startDate ?? $today, + 'data_scadenza' => $endDate ?? $due, + 'note' => 'Discussion #238 regression', + 'consegna_immediata' => '1', + 'scarica_pdf' => '0', + ]; + if ($saveAndNew) { + $body['save_and_new'] = '1'; + } + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/admin/loans/store') + ->withParsedBody($body); + + return (new PrestitiController())->store( + $request, + (new ResponseFactory())->createResponse(), + $db + ); +}; + +$callApproval = static function (int $loanId) use ($db) { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/admin/loans/approve') + ->withParsedBody(['loan_id' => (string) $loanId]); + + return (new LoanApprovalController())->approveLoan( + $request, + (new ResponseFactory())->createResponse(), + $db + ); +}; + +$cleanup(); +$settings = new SettingsRepository($db); + +try { + /* ================= setting accessor: default and strict parsing ====== */ + $db->query( + "DELETE FROM system_settings + WHERE category = 'loans' AND setting_key = 'allow_multiple_loans_same_book'" + ); + ConfigStore::clearCache(); + $check(!$settings->allowsMultipleLoansSameBook(), 'missing setting keeps the safe OFF default'); + + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $check($settings->allowsMultipleLoansSameBook(), "only persisted value '1' enables multiplicity"); + + foreach (['0', 'true', 'yes', '01', ''] as $malformed) { + $settings->set('loans', 'allow_multiple_loans_same_book', $malformed); + $check( + !$settings->allowsMultipleLoansSameBook(), + "value " . var_export($malformed, true) . ' fails closed' + ); + } + + // Keep capacity limits out of these fixtures; the feature must not alter + // that independent rule (covered by the existing loan limit test suites). + $settings->set('loans', 'max_active_loans_per_user', '0'); + + /* ================= central policy: OFF, ON and unbound rows ========= */ + $policyUser = $makeUser(); + [$policyBook, $policyCopies] = $makeBook(3); + $activeBoundLoan = $makeLoan($policyBook, $policyCopies[0], $policyUser, 'in_corso', 1); + + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + $policy = new LoanMultiplicityPolicy($db); + $check( + $policy->hasBlockingLoan($policyBook, $policyUser, true), + 'OFF blocks a second borrower/title loan even when both operations bind copies' + ); + + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $policy = new LoanMultiplicityPolicy($db); + $check( + !$policy->hasBlockingLoan($policyBook, $policyUser, true), + 'ON ignores an existing copy-bound sibling for a new copy-bound operation' + ); + $check( + $policy->hasBlockingLoan($policyBook, $policyUser, false), + 'ON remains strict when the proposed operation will not bind a physical copy' + ); + + $sameCopyRejected = false; + try { + $makeLoan($policyBook, $policyCopies[0], $policyUser, 'in_corso', 1); + } catch (mysqli_sql_exception $e) { + $sameCopyRejected = true; + } + $sameCopyCount = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE copia_id = {$policyCopies[0]} AND attivo = 1" + )->fetch_row()[0]; + $check( + $sameCopyRejected && $sameCopyCount === 1, + 'the database overlap guard still forbids two open loans on the same physical copy' + ); + + $pendingLoan = $makeLoan($policyBook, null, $policyUser, 'pendente', 0); + $check( + $policy->hasBlockingLoan($policyBook, $policyUser, true), + 'a sibling pending/copyless request remains blocking while ON' + ); + $check( + !$policy->hasBlockingLoan($policyBook, $policyUser, true, $pendingLoan), + 'excluding the loan being approved does not make its own pending row self-conflict' + ); + $check( + $policy->hasBlockingLoan($policyBook, $policyUser, false, $pendingLoan), + 'exclude-id does not relax an unbound operation against another open title loan' + ); + + $db->query("UPDATE prestiti SET stato = 'annullato', attivo = 0 WHERE id = {$pendingLoan}"); + $activeCopylessLoan = $makeLoan($policyBook, null, $policyUser, 'in_corso', 1); + $check( + $policy->hasBlockingLoan($policyBook, $policyUser, true), + 'a legacy active copyless loan remains blocking while ON' + ); + $check( + !$policy->hasBlockingLoan($policyBook, $policyUser, true, $activeCopylessLoan), + 'exclude-id correctly removes the legacy row currently being changed' + ); + + /* ================= shared open-copy commitment API ================= */ + // Exercise the database-backed selector and the pure comparator together: + // callers receive one normalized definition of an open physical commitment. + $apiSameUser = $makeUser(); + $apiOtherUser = $makeUser(); + $apiCandidateUser = $makeUser(); + [$apiBook, $apiCopies] = $makeBook(3); + $apiFirstStart = (new DateTimeImmutable($today))->modify('+70 days')->format('Y-m-d'); + $apiFirstEnd = (new DateTimeImmutable($today))->modify('+75 days')->format('Y-m-d'); + $apiSecondStart = (new DateTimeImmutable($today))->modify('+85 days')->format('Y-m-d'); + $apiSecondEnd = (new DateTimeImmutable($today))->modify('+90 days')->format('Y-m-d'); + $apiUserStart = (new DateTimeImmutable($today))->modify('+100 days')->format('Y-m-d'); + $apiUserEnd = (new DateTimeImmutable($today))->modify('+105 days')->format('Y-m-d'); + $apiSameUserLoan = $makeLoan( + $apiBook, + $apiCopies[0], + $apiSameUser, + 'prenotato', + 1, + $apiFirstStart, + $apiFirstEnd + ); + $apiOtherUserLoan = $makeLoan( + $apiBook, + $apiCopies[0], + $apiOtherUser, + 'prenotato', + 1, + $apiSecondStart, + $apiSecondEnd + ); + $apiSameUserOtherCopyLoan = $makeLoan( + $apiBook, + $apiCopies[1], + $apiSameUser, + 'da_ritirare', + 1, + $apiUserStart, + $apiUserEnd + ); + $makeLoan( + $apiBook, + $apiCopies[0], + $apiCandidateUser, + 'restituito', + 0, + $apiFirstStart, + $apiFirstEnd + ); + $apiOverdueStart = (new DateTimeImmutable($today))->modify('-20 days')->format('Y-m-d'); + $apiOverdueEnd = (new DateTimeImmutable($today))->modify('-10 days')->format('Y-m-d'); + $apiOverdueLoan = $makeLoan( + $apiBook, + $apiCopies[2], + $apiCandidateUser, + 'in_ritardo', + 1, + $apiOverdueStart, + $apiOverdueEnd + ); + + $apiPolicy = new LoanMultiplicityPolicy($db); + $db->begin_transaction(); + try { + $apiBookLock = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); + $apiBookLock->bind_param('i', $apiBook); + $apiBookLock->execute(); + $apiBookLock->get_result()->fetch_assoc(); + $apiBookLock->close(); + + $apiCopyCommitments = $apiPolicy->lockOpenCopyCommitments( + $apiBook, + copyId: $apiCopies[0] + ); + $apiUserCommitmentsExcludingCandidate = $apiPolicy->lockOpenCopyCommitments( + $apiBook, + userId: $apiSameUser, + excludeLoanId: $apiSameUserLoan + ); + $apiOverdueCommitments = $apiPolicy->lockOpenCopyCommitments( + $apiBook, + copyId: $apiCopies[2] + ); + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + + $check( + $apiCopyCommitments === [ + [ + 'loanId' => $apiSameUserLoan, + 'copyId' => $apiCopies[0], + 'userId' => $apiSameUser, + 'startDate' => $apiFirstStart, + 'endDate' => $apiFirstEnd, + 'state' => 'prenotato', + ], + [ + 'loanId' => $apiOtherUserLoan, + 'copyId' => $apiCopies[0], + 'userId' => $apiOtherUser, + 'startDate' => $apiSecondStart, + 'endDate' => $apiSecondEnd, + 'state' => 'prenotato', + ], + ], + 'copy selector returns normalized open commitments and excludes terminal rows' + ); + $check( + $apiUserCommitmentsExcludingCandidate === [[ + 'loanId' => $apiSameUserOtherCopyLoan, + 'copyId' => $apiCopies[1], + 'userId' => $apiSameUser, + 'startDate' => $apiUserStart, + 'endDate' => $apiUserEnd, + 'state' => 'da_ritirare', + ]], + 'user selector spans copies and excludeLoanId removes only the candidate row' + ); + + $apiFarStart = (new DateTimeImmutable($today))->modify('+365 days')->format('Y-m-d'); + $apiFarEnd = (new DateTimeImmutable($today))->modify('+370 days')->format('Y-m-d'); + $check( + LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 0, + $apiSameUser, + $apiFarStart, + $apiFarEnd, + $apiCopyCommitments + ), + 'same borrower/title identity conflicts even when the date windows are disjoint' + ); + $check( + LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 0, + $apiCandidateUser, + $apiFirstEnd, + (new DateTimeImmutable($apiFirstEnd))->modify('+2 days')->format('Y-m-d'), + $apiCopyCommitments + ), + 'another borrower conflicts when its window touches an existing end date inclusively' + ); + $check( + !LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 0, + $apiCandidateUser, + (new DateTimeImmutable($apiSecondEnd))->modify('+1 day')->format('Y-m-d'), + (new DateTimeImmutable($apiSecondEnd))->modify('+5 days')->format('Y-m-d'), + $apiCopyCommitments + ), + 'another borrower does not conflict when every locked window is disjoint' + ); + $check( + LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 0, + $apiSameUser, + $apiFarStart, + $apiFarEnd, + $apiOverdueCommitments + ), + 'an overdue commitment remains open-ended for a different borrower' + ); + $check( + !LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + $apiSameUserLoan, + $apiCandidateUser, + $apiFirstStart, + $apiFirstEnd, + $apiCopyCommitments + ) && ($apiOverdueCommitments[0]['loanId'] ?? 0) === $apiOverdueLoan, + 'the comparator excludes its candidate loan without hiding other normalized rows' + ); + + /* ================= real staff creation and quick-copy workflow ====== */ + $admin = $makeUser('admin'); + $multiBorrower = $makeUser(); + [$multiBook, $multiCopyIds, $multiCopyCodes] = $makeBook(3); + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + + $response = $callStore($admin, $multiBorrower, $multiBook, $multiCopyCodes[0], true); + $check( + $response->getStatusCode() === 302 && str_contains($response->getHeaderLine('Location'), 'created=1'), + 'real store() creates the first scanned physical-copy loan in quick mode' + ); + $retained = $_SESSION['loan_form_old'] ?? []; + $check( + (int) ($retained['libro_id'] ?? 0) === $multiBook + && (int) ($retained['utente_id'] ?? 0) === $multiBorrower + && ($retained['note'] ?? '') === 'Discussion #238 regression', + 'quick mode retains borrower, title, dates and note while multiplicity is ON' + ); + $check( + !array_key_exists('copy_code', $retained) && !array_key_exists('save_and_new', $retained), + 'quick mode always clears the physical-copy code and one-shot submit flag' + ); + + $response = $callStore($admin, $multiBorrower, $multiBook, $multiCopyCodes[0]); + $check( + str_contains($response->getHeaderLine('Location'), 'error=copy_not_available') + && $loanCount($multiBook, $multiBorrower) === 1, + 'real store() still rejects reusing the same scanned copy while ON' + ); + + $response = $callStore($admin, $multiBorrower, $multiBook, $multiCopyCodes[1]); + $distinctCopies = $db->query( + "SELECT COUNT(DISTINCT copia_id), COUNT(*) + FROM prestiti + WHERE libro_id = {$multiBook} AND utente_id = {$multiBorrower} AND attivo = 1" + )->fetch_row(); + $check( + str_contains($response->getHeaderLine('Location'), 'created=1') + && (int) $distinctCopies[0] === 2 + && (int) $distinctCopies[1] === 2, + 'real store() permits two simultaneous loans of distinct copies of one title while ON' + ); + + $replayBorrower = $makeUser(); + [$replayBook, $replayCopyIds] = $makeBook(2); + $replayToken = \App\Support\OneTimeFormToken::issue('loan.create'); + $firstReplayResponse = $callStore( + $admin, + $replayBorrower, + $replayBook, + '', + false, + null, + null, + $replayToken + ); + $secondReplayResponse = $callStore( + $admin, + $replayBorrower, + $replayBook, + '', + false, + null, + null, + $replayToken + ); + $check( + str_contains($firstReplayResponse->getHeaderLine('Location'), 'created=1') + && str_contains($secondReplayResponse->getHeaderLine('Location'), 'error=duplicate_submission') + && $loanCount($replayBook, $replayBorrower) === 1 + && count($replayCopyIds) === 2, + 'server-side one-time token rejects a replay before it auto-assigns another copy' + ); + + // Each physical copy is still a full active loan for the independent + // borrower cap. The opt-in changes title multiplicity, never quota math. + $settings->set('loans', 'max_active_loans_per_user', '2'); + $response = $callStore($admin, $multiBorrower, $multiBook, $multiCopyCodes[2]); + $thirdCopyLoanCount = (int) $db->query( + "SELECT COUNT(*) FROM prestiti + WHERE libro_id = {$multiBook} AND utente_id = {$multiBorrower} AND attivo = 1" + )->fetch_row()[0]; + $thirdCopyAssigned = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE copia_id = {$multiCopyIds[2]}" + )->fetch_row()[0]; + $check( + str_contains($response->getHeaderLine('Location'), 'error=max_loans_reached') + && $thirdCopyLoanCount === 2 + && $thirdCopyAssigned === 0, + 'max_active_loans_per_user counts every same-title copy and blocks the next loan' + ); + $settings->set('loans', 'max_active_loans_per_user', '0'); + + // P2 regression: copy identity is stronger than date overlap. An open + // future commitment to copy A means a second open same-borrower/title row + // must represent copy B, even when the requested windows are disjoint. + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $windowBorrower = $makeUser(); + [$windowBook, $windowCopies, $windowCopyCodes] = $makeBook(2); + $futureStart = (new DateTimeImmutable($today))->modify('+20 days')->format('Y-m-d'); + $futureEnd = (new DateTimeImmutable($futureStart))->modify('+5 days')->format('Y-m-d'); + $scheduledCopyLoan = $makeLoan( + $windowBook, + $windowCopies[0], + $windowBorrower, + 'prenotato', + 1, + $futureStart, + $futureEnd + ); + + $windowPolicy = new LoanMultiplicityPolicy($db); + $committedBefore = $windowPolicy->committedCopyIds($windowBook, $windowBorrower); + $committedExcludingCurrent = $windowPolicy->committedCopyIds( + $windowBook, + $windowBorrower, + $scheduledCopyLoan + ); + $check( + $committedBefore === [$windowCopies[0]] && $committedExcludingCurrent === [], + 'committedCopyIds tracks physical identity and honours excludeLoanId' + ); + + $response = $callStore( + $admin, + $windowBorrower, + $windowBook, + $windowCopyCodes[0] + ); + $check( + str_contains($response->getHeaderLine('Location'), 'error=copy_not_available') + && $loanCount($windowBook, $windowBorrower) === 1, + 'explicit copy reuse is rejected for disjoint same-borrower/title windows while ON' + ); + + $response = $callStore($admin, $windowBorrower, $windowBook, ''); + $automaticCopyRow = $db->query( + "SELECT copia_id FROM prestiti + WHERE libro_id = {$windowBook} AND utente_id = {$windowBorrower} AND id <> {$scheduledCopyLoan} + ORDER BY id DESC LIMIT 1" + )->fetch_assoc() ?: []; + $check( + str_contains($response->getHeaderLine('Location'), 'created=1') + && $loanCount($windowBook, $windowBorrower) === 2 + && (int) ($automaticCopyRow['copia_id'] ?? 0) === $windowCopies[1], + 'automatic assignment skips the committed copy and chooses another physical copy for a disjoint window' + ); + + // Queue reservations are book-level commitments and deliberately remain + // unique even though distinct copy-bound staff loans may coexist. + $reservedBorrower = $makeUser(); + [$reservedBook, , $reservedCopyCodes] = $makeBook(2); + $activeReservation = $makeActiveReservation($reservedBook, $reservedBorrower); + $response = $callStore($admin, $reservedBorrower, $reservedBook, $reservedCopyCodes[0]); + $reservationStateStmt = $db->prepare('SELECT stato FROM prenotazioni WHERE id = ?'); + $reservationStateStmt->bind_param('i', $activeReservation); + $reservationStateStmt->execute(); + $reservationState = (string) $reservationStateStmt->get_result()->fetch_row()[0]; + $reservationStateStmt->close(); + $check( + str_contains($response->getHeaderLine('Location'), 'error=duplicate_reservation') + && $loanCount($reservedBook, $reservedBorrower) === 0 + && $reservationState === 'attiva', + 'an active same-user/title reservation still blocks real store() while ON' + ); + + // A scheduled reservation remains a title-level commitment even when it + // already pins a physical copy. Only loans that represent physical items + // in circulation may coexist under the opt-in policy. + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $pinnedReservationBorrower = $makeUser(); + [$pinnedReservationBook, $pinnedReservationCopies, $pinnedReservationCodes] = $makeBook(2); + $pinnedReservationStart = (new DateTimeImmutable($today))->modify('+20 days')->format('Y-m-d'); + $pinnedReservationEnd = (new DateTimeImmutable($today))->modify('+25 days')->format('Y-m-d'); + $pinnedReservationLoan = $makeLoan( + $pinnedReservationBook, + $pinnedReservationCopies[0], + $pinnedReservationBorrower, + 'prenotato', + 1, + $pinnedReservationStart, + $pinnedReservationEnd, + 'prenotazione' + ); + $response = $callStore( + $admin, + $pinnedReservationBorrower, + $pinnedReservationBook, + $pinnedReservationCodes[1] + ); + $pinnedReservationStmt = $db->prepare( + 'SELECT stato, attivo, copia_id FROM prestiti WHERE id = ?' + ); + $pinnedReservationStmt->bind_param('i', $pinnedReservationLoan); + $pinnedReservationStmt->execute(); + $pinnedReservationRow = $pinnedReservationStmt->get_result()->fetch_assoc() ?: []; + $pinnedReservationStmt->close(); + $check( + str_contains($response->getHeaderLine('Location'), 'error=duplicate_reservation') + && $loanCount($pinnedReservationBook, $pinnedReservationBorrower) === 1 + && ($pinnedReservationRow['stato'] ?? '') === 'prenotato' + && (int) ($pinnedReservationRow['attivo'] ?? 0) === 1 + && (int) ($pinnedReservationRow['copia_id'] ?? 0) === $pinnedReservationCopies[0], + 'a copy-bound active reservation remains blocking while multiplicity is ON' + ); + + // Scheduled direct loans are physical-copy commitments, not queue + // reservations: with the option ON, a distinct copy may coexist. + $directScheduledBorrower = $makeUser(); + [$directScheduledBook, $directScheduledCopies, $directScheduledCodes] = $makeBook(2); + $directScheduledStart = (new DateTimeImmutable($today))->modify('+30 days')->format('Y-m-d'); + $directScheduledEnd = (new DateTimeImmutable($today))->modify('+35 days')->format('Y-m-d'); + $directScheduledLoan = $makeLoan( + $directScheduledBook, + $directScheduledCopies[0], + $directScheduledBorrower, + 'prenotato', + 1, + $directScheduledStart, + $directScheduledEnd + ); + $response = $callStore( + $admin, + $directScheduledBorrower, + $directScheduledBook, + $directScheduledCodes[1] + ); + $directScheduledStmt = $db->prepare( + "SELECT COUNT(*) AS total, COUNT(DISTINCT copia_id) AS physical_copies, + SUM(CASE WHEN id = ? AND origine = 'diretto' AND stato = 'prenotato' THEN 1 ELSE 0 END) AS original_ok + FROM prestiti WHERE libro_id = ? AND utente_id = ? AND attivo = 1" + ); + $directScheduledStmt->bind_param( + 'iii', + $directScheduledLoan, + $directScheduledBook, + $directScheduledBorrower + ); + $directScheduledStmt->execute(); + $directScheduledRows = $directScheduledStmt->get_result()->fetch_assoc() ?: []; + $directScheduledStmt->close(); + $check( + str_contains($response->getHeaderLine('Location'), 'created=1') + && (int) ($directScheduledRows['total'] ?? 0) === 2 + && (int) ($directScheduledRows['physical_copies'] ?? 0) === 2 + && (int) ($directScheduledRows['original_ok'] ?? 0) === 1, + 'a copy-bound direct prenotato loan may coexist with another distinct copy while ON' + ); + + // The same distinction holds during the direct pickup-ready phase. + $directPickupBorrower = $makeUser(); + [$directPickupBook, $directPickupCopies, $directPickupCodes] = $makeBook(2); + $directPickupLoan = $makeLoan( + $directPickupBook, + $directPickupCopies[0], + $directPickupBorrower, + 'da_ritirare', + 1 + ); + $response = $callStore( + $admin, + $directPickupBorrower, + $directPickupBook, + $directPickupCodes[1] + ); + $directPickupStmt = $db->prepare( + "SELECT COUNT(*) AS total, COUNT(DISTINCT copia_id) AS physical_copies, + SUM(CASE WHEN id = ? AND origine = 'diretto' AND stato = 'da_ritirare' THEN 1 ELSE 0 END) AS original_ok + FROM prestiti WHERE libro_id = ? AND utente_id = ? AND attivo = 1" + ); + $directPickupStmt->bind_param('iii', $directPickupLoan, $directPickupBook, $directPickupBorrower); + $directPickupStmt->execute(); + $directPickupRows = $directPickupStmt->get_result()->fetch_assoc() ?: []; + $directPickupStmt->close(); + $check( + str_contains($response->getHeaderLine('Location'), 'created=1') + && (int) ($directPickupRows['total'] ?? 0) === 2 + && (int) ($directPickupRows['physical_copies'] ?? 0) === 2 + && (int) ($directPickupRows['original_ok'] ?? 0) === 1, + 'a copy-bound direct da_ritirare loan may coexist with another distinct copy while ON' + ); + + /* ================= strict mode remains backward compatible ========== */ + $strictBorrower = $makeUser(); + [$strictBook, , $strictCopyCodes] = $makeBook(2); + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + + $response = $callStore($admin, $strictBorrower, $strictBook, $strictCopyCodes[0], true); + $check(str_contains($response->getHeaderLine('Location'), 'created=1'), 'strict mode still creates a normal first loan'); + $strictRetained = $_SESSION['loan_form_old'] ?? []; + $check( + !array_key_exists('libro_id', $strictRetained), + 'strict quick mode preserves the historical behaviour of clearing the title' + ); + + $response = $callStore($admin, $strictBorrower, $strictBook, $strictCopyCodes[1]); + $check( + str_contains($response->getHeaderLine('Location'), 'error=duplicate_reservation') + && $loanCount($strictBook, $strictBorrower) === 1, + 'strict mode rejects a second distinct copy for the same borrower/title' + ); + + /* ================= real pending-loan approval semantics ============= */ + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $approvalUser = $makeUser(); + [$approvalBook, $approvalCopies] = $makeBook(2); + $makeLoan($approvalBook, $approvalCopies[0], $approvalUser, 'in_corso', 1); + $loanToApprove = $makeLoan($approvalBook, null, $approvalUser, 'pendente', 0); + + $response = $callApproval($loanToApprove); + $approvalBody = (array) json_decode((string) $response->getBody(), true); + $approvedRow = $db->query( + "SELECT stato, attivo, copia_id FROM prestiti WHERE id = {$loanToApprove}" + )->fetch_assoc() ?: []; + $check( + $response->getStatusCode() === 200 + && ($approvalBody['success'] ?? null) === true + && ($approvedRow['stato'] ?? '') === 'da_ritirare' + && (int) ($approvedRow['attivo'] ?? 0) === 1 + && (int) ($approvedRow['copia_id'] ?? 0) === $approvalCopies[1], + 'real approveLoan() assigns a distinct copy beside an existing copy-bound loan while ON' + ); + + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + $strictApprovalUser = $makeUser(); + [$strictApprovalBook, $strictApprovalCopies] = $makeBook(2); + $makeLoan($strictApprovalBook, $strictApprovalCopies[0], $strictApprovalUser, 'in_corso', 1); + $strictPendingLoan = $makeLoan($strictApprovalBook, null, $strictApprovalUser, 'pendente', 0); + + $response = $callApproval($strictPendingLoan); + $strictApprovalRow = $db->query( + "SELECT stato, attivo, copia_id FROM prestiti WHERE id = {$strictPendingLoan}" + )->fetch_assoc() ?: []; + $check( + $response->getStatusCode() === 409 + && ($strictApprovalRow['stato'] ?? '') === 'pendente' + && (int) ($strictApprovalRow['attivo'] ?? 1) === 0 + && $strictApprovalRow['copia_id'] === null, + 'real approveLoan() remains title-strict beside an active loan while OFF' + ); + + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $pendingSiblingUser = $makeUser(); + [$pendingSiblingBook] = $makeBook(2); + $pendingCandidate = $makeLoan($pendingSiblingBook, null, $pendingSiblingUser, 'pendente', 0); + $pendingSibling = $makeLoan($pendingSiblingBook, null, $pendingSiblingUser, 'pendente', 0); + + $response = $callApproval($pendingCandidate); + $pendingRows = (int) $db->query( + "SELECT COUNT(*) FROM prestiti + WHERE id IN ({$pendingCandidate}, {$pendingSibling}) AND stato = 'pendente' AND attivo = 0 AND copia_id IS NULL" + )->fetch_row()[0]; + $check( + $response->getStatusCode() === 409 && $pendingRows === 2, + 'real approveLoan() keeps a sibling pending/copyless request blocking while ON' + ); + + /* ================= real reassignment semantics ====================== */ + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $reassignTarget = $makeUser(); + $reassignSource = $makeUser(); + [$reassignBook, $reassignCopies] = $makeBook(2); + $makeLoan($reassignBook, $reassignCopies[0], $reassignTarget, 'in_corso', 1); + $loanToReassign = $makeLoan($reassignBook, $reassignCopies[1], $reassignSource, 'in_corso', 1); + + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $admin]; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/admin/loans/edit/{$loanToReassign}") + ->withParsedBody(['utente_id' => (string) $reassignTarget]); + $response = (new PrestitiController())->update( + $request, + (new ResponseFactory())->createResponse(), + $db, + $loanToReassign + ); + $reassignedUser = (int) $db->query( + "SELECT utente_id FROM prestiti WHERE id = {$loanToReassign}" + )->fetch_row()[0]; + $check( + !str_contains($response->getHeaderLine('Location'), 'error=') && $reassignedUser === $reassignTarget, + 'real update() permits reassignment when both same-title loans have distinct copies' + ); + + $pickupSource = $makeUser(); + $pickupTarget = $makeUser(); + [$pickupReassignBook, $pickupReassignCopies] = $makeBook(1); + $pickupReassignLoan = $makeLoan( + $pickupReassignBook, + $pickupReassignCopies[0], + $pickupSource, + 'da_ritirare', + 1 + ); + $db->query("UPDATE prestiti SET pickup_notification_sent = 1 WHERE id = {$pickupReassignLoan}"); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/admin/loans/edit/{$pickupReassignLoan}") + ->withParsedBody(['utente_id' => (string) $pickupTarget]); + $response = (new PrestitiController())->update( + $request, + (new ResponseFactory())->createResponse(), + $db, + $pickupReassignLoan + ); + $pickupReassigned = $db->query( + "SELECT utente_id, pickup_notification_sent FROM prestiti WHERE id = {$pickupReassignLoan}" + )->fetch_assoc() ?: []; + $check( + !str_contains($response->getHeaderLine('Location'), 'error=') + && (int) ($pickupReassigned['utente_id'] ?? 0) === $pickupTarget + && (int) ($pickupReassigned['pickup_notification_sent'] ?? 1) === 0, + 'reassigning a ready-for-pickup loan releases the old recipient notification claim' + ); + + $legacyTarget = $makeUser(); + $legacySource = $makeUser(); + [$legacyBook, $legacyCopies] = $makeBook(1); + $makeLoan($legacyBook, $legacyCopies[0], $legacyTarget, 'in_corso', 1); + $legacyCopylessLoan = $makeLoan($legacyBook, null, $legacySource, 'in_corso', 1); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/admin/loans/edit/{$legacyCopylessLoan}") + ->withParsedBody(['utente_id' => (string) $legacyTarget]); + $response = (new PrestitiController())->update( + $request, + (new ResponseFactory())->createResponse(), + $db, + $legacyCopylessLoan + ); + $legacyUserAfter = (int) $db->query( + "SELECT utente_id FROM prestiti WHERE id = {$legacyCopylessLoan}" + )->fetch_row()[0]; + $check( + str_contains($response->getHeaderLine('Location'), 'error=duplicate_reservation') + && $legacyUserAfter === $legacySource, + 'real update() keeps legacy copyless reassignment strict while ON' + ); + + /* ================= lifecycle allocation after disabling the toggle === */ + // Existing duplicate rows are legitimate historical state. Turning the + // option OFF must prevent new duplicates, not let background lifecycle + // allocators collapse two rows back onto one physical item. + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $newCopyUser = $makeUser(); + [$newCopyBook, $newCopyCopies] = $makeBook(2); + $newCopySiblingStart = (new DateTimeImmutable($today))->modify('+30 days')->format('Y-m-d'); + $newCopySiblingEnd = (new DateTimeImmutable($today))->modify('+35 days')->format('Y-m-d'); + $newCopyTargetStart = (new DateTimeImmutable($today))->modify('+40 days')->format('Y-m-d'); + $newCopyTargetEnd = (new DateTimeImmutable($today))->modify('+45 days')->format('Y-m-d'); + $newCopySibling = $makeLoan( + $newCopyBook, + $newCopyCopies[0], + $newCopyUser, + 'prenotato', + 1, + $newCopySiblingStart, + $newCopySiblingEnd + ); + $newCopyTarget = $makeLoan( + $newCopyBook, + null, + $newCopyUser, + 'prenotato', + 1, + $newCopyTargetStart, + $newCopyTargetEnd + ); + $newCopyFallbackUser = $makeUser(); + $newCopyFallback = $makeLoan( + $newCopyBook, + null, + $newCopyFallbackUser, + 'prenotato', + 1, + $newCopyTargetStart, + $newCopyTargetEnd + ); + // Make FIFO deterministic even on databases whose TIMESTAMP precision is + // one second: the incompatible borrower is first, the compatible one next. + $fifoCreatedStmt = $db->prepare('UPDATE prestiti SET created_at = ? WHERE id = ?'); + $fifoCreatedAt = '2001-01-01 00:00:00'; + $fifoLoanId = $newCopyTarget; + $fifoCreatedStmt->bind_param('si', $fifoCreatedAt, $fifoLoanId); + $fifoCreatedStmt->execute(); + $fifoCreatedAt = '2001-01-01 00:00:01'; + $fifoLoanId = $newCopyFallback; + $fifoCreatedStmt->execute(); + $fifoCreatedStmt->close(); + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + + $committedAfterDisable = (new LoanMultiplicityPolicy($db))->committedCopyIds( + $newCopyBook, + $newCopyUser, + $newCopyTarget + ); + $check( + $committedAfterDisable === [$newCopyCopies[0]], + 'disabling the toggle preserves committed-copy knowledge for existing duplicate lifecycle rows' + ); + + $db->begin_transaction(); + try { + (new ReservationReassignmentService($db)) + ->setExternalTransaction(true) + ->reassignOnNewCopy($newCopyBook, $newCopyCopies[0]); + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + $fifoAssignmentStmt = $db->prepare( + 'SELECT id, copia_id FROM prestiti WHERE id IN (?, ?) ORDER BY id' + ); + $fifoAssignmentStmt->bind_param('ii', $newCopyTarget, $newCopyFallback); + $fifoAssignmentStmt->execute(); + $fifoAssignments = $fifoAssignmentStmt->get_result()->fetch_all(MYSQLI_ASSOC); + $fifoAssignmentStmt->close(); + $fifoCopyByLoan = []; + foreach ($fifoAssignments as $fifoAssignment) { + $fifoCopyByLoan[(int) $fifoAssignment['id']] = $fifoAssignment['copia_id'] !== null + ? (int) $fifoAssignment['copia_id'] + : null; + } + $check( + ($fifoCopyByLoan[$newCopyTarget] ?? null) === null + && ($fifoCopyByLoan[$newCopyFallback] ?? null) === $newCopyCopies[0], + 'reassignOnNewCopy() skips an incompatible FIFO head and assigns the returned copy to the next borrower' + ); + + $db->begin_transaction(); + try { + (new ReservationReassignmentService($db)) + ->setExternalTransaction(true) + ->reassignOnNewCopy($newCopyBook, $newCopyCopies[1]); + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + $newCopyAssignmentStmt = $db->prepare( + 'SELECT id, copia_id FROM prestiti WHERE id IN (?, ?, ?) ORDER BY id' + ); + $newCopyAssignmentStmt->bind_param('iii', $newCopySibling, $newCopyTarget, $newCopyFallback); + $newCopyAssignmentStmt->execute(); + $newCopyAssignments = $newCopyAssignmentStmt->get_result()->fetch_all(MYSQLI_ASSOC); + $newCopyAssignmentStmt->close(); + $newCopyByLoan = []; + foreach ($newCopyAssignments as $newCopyAssignment) { + $newCopyByLoan[(int) $newCopyAssignment['id']] = $newCopyAssignment['copia_id'] !== null + ? (int) $newCopyAssignment['copia_id'] + : null; + } + $check( + count($newCopyAssignments) === 3 + && ($newCopyByLoan[$newCopySibling] ?? null) === $newCopyCopies[0] + && ($newCopyByLoan[$newCopyTarget] ?? null) === $newCopyCopies[1] + && ($newCopyByLoan[$newCopyFallback] ?? null) === $newCopyCopies[0], + 'reassignOnNewCopy() assigns the alternative without changing either earlier commitment' + ); + + // Copy-loss allocator: copy A is already committed by this borrower/title, + // copy B is lost, so the disjoint hold must move to copy C rather than A. + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $copyLostUser = $makeUser(); + [$copyLostBook, $copyLostCopies] = $makeBook(3); + $copyLostSiblingStart = (new DateTimeImmutable($today))->modify('+50 days')->format('Y-m-d'); + $copyLostSiblingEnd = (new DateTimeImmutable($today))->modify('+55 days')->format('Y-m-d'); + $copyLostTargetStart = (new DateTimeImmutable($today))->modify('+60 days')->format('Y-m-d'); + $copyLostTargetEnd = (new DateTimeImmutable($today))->modify('+65 days')->format('Y-m-d'); + $copyLostSibling = $makeLoan( + $copyLostBook, + $copyLostCopies[0], + $copyLostUser, + 'prenotato', + 1, + $copyLostSiblingStart, + $copyLostSiblingEnd + ); + $copyLostTarget = $makeLoan( + $copyLostBook, + $copyLostCopies[1], + $copyLostUser, + 'prenotato', + 1, + $copyLostTargetStart, + $copyLostTargetEnd + ); + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + $db->query("UPDATE copie SET stato = 'danneggiato' WHERE id = {$copyLostCopies[1]}"); + + (new ReservationReassignmentService($db))->reassignOnCopyLost($copyLostCopies[1]); + $copyLostAssignments = $db->query( + "SELECT id, copia_id FROM prestiti WHERE id IN ({$copyLostSibling}, {$copyLostTarget}) ORDER BY id" + )->fetch_all(MYSQLI_ASSOC); + $check( + count($copyLostAssignments) === 2 + && (int) $copyLostAssignments[0]['copia_id'] === $copyLostCopies[0] + && (int) $copyLostAssignments[1]['copia_id'] === $copyLostCopies[2], + 'reassignOnCopyLost() skips the committed sibling copy and chooses the distinct alternative after toggle OFF' + ); + + // Maintenance allocator: a legacy/unpinned loan whose window starts today + // must become ready on copy B; copy A belongs to its same-title future + // sibling even though the two windows are disjoint. + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $maintenanceUser = $makeUser(); + [$maintenanceBook, $maintenanceCopies] = $makeBook(2); + $maintenanceSiblingStart = (new DateTimeImmutable($today))->modify('+10 days')->format('Y-m-d'); + $maintenanceSiblingEnd = (new DateTimeImmutable($today))->modify('+15 days')->format('Y-m-d'); + $maintenanceTargetEnd = (new DateTimeImmutable($today))->modify('+5 days')->format('Y-m-d'); + $maintenanceSibling = $makeLoan( + $maintenanceBook, + $maintenanceCopies[0], + $maintenanceUser, + 'prenotato', + 1, + $maintenanceSiblingStart, + $maintenanceSiblingEnd + ); + $maintenanceTarget = $makeLoan( + $maintenanceBook, + null, + $maintenanceUser, + 'prenotato', + 1, + $today, + $maintenanceTargetEnd + ); + $settings->set('loans', 'allow_multiple_loans_same_book', '0'); + + $activated = (new MaintenanceService($db))->activateScheduledLoans(); + $maintenanceAssignments = $db->query( + "SELECT id, stato, copia_id FROM prestiti + WHERE id IN ({$maintenanceSibling}, {$maintenanceTarget}) ORDER BY id" + )->fetch_all(MYSQLI_ASSOC); + $check( + $activated >= 1 + && count($maintenanceAssignments) === 2 + && (int) $maintenanceAssignments[0]['copia_id'] === $maintenanceCopies[0] + && $maintenanceAssignments[0]['stato'] === 'prenotato' + && (int) $maintenanceAssignments[1]['copia_id'] === $maintenanceCopies[1] + && $maintenanceAssignments[1]['stato'] === 'da_ritirare', + 'activateScheduledLoans() assigns an unpinned loan to the distinct alternative after toggle OFF' + ); + + // The variable is intentionally referenced so static analysers also verify + // that our initial policy fixture was actually created and not optimized away. + $check($activeBoundLoan > 0 && count($multiCopyIds) === 3, 'fixtures retain explicit physical-copy identity'); +} catch (Throwable $e) { + $failed++; + fwrite(STDERR, "UNCAUGHT TEST ERROR: {$e->getMessage()}\n{$e->getTraceAsString()}\n"); +} finally { + try { + // Harmless in autocommit mode; essential if a controller threw after + // opening a transaction, so fixture cleanup never inherits row locks. + $db->rollback(); + } catch (Throwable $e) { + // No active transaction is the normal path. + } + + try { + $cleanup(); + } catch (Throwable $e) { + $failed++; + fwrite(STDERR, "CLEANUP ERROR: {$e->getMessage()}\n"); + } + + try { + $restoreSetting('allow_multiple_loans_same_book', $originalMultiplicity); + $restoreSetting('max_active_loans_per_user', $originalMaxLoans); + } catch (Throwable $e) { + $failed++; + fwrite(STDERR, "SETTING RESTORE ERROR: {$e->getMessage()}\n"); + } + + $_SESSION = $sessionBefore; +} + +echo "\nPassed: " . ($testNo - $failed) . " Failed: {$failed}\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/mvcc-lockfirst-circulation.unit.php b/tests/mvcc-lockfirst-circulation.unit.php index fbc6afde8..13c717bd3 100644 --- a/tests/mvcc-lockfirst-circulation.unit.php +++ b/tests/mvcc-lockfirst-circulation.unit.php @@ -79,6 +79,11 @@ function mvccenv(string $path): array ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $dbUser, $dbPass, $dbName, (int) ($env['DB_PORT'] ?? 3306)); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); return $db; }; @@ -348,7 +353,15 @@ function check(bool $cond, string $desc): void $rmSource = $readSource('/app/Controllers/ReservationManager.php'); $pba = $extractMethod($rmSource, 'function processBookAvailability('); - check((bool) preg_match('/FROM prenotazioni r\s+JOIN utenti u ON r\.utente_id = u\.id.*?LIMIT 1\s+FOR UPDATE/s', $pba), 'ReservationManager: queue read is a locking read (FOR UPDATE)'); + $fifoLockingRead = (bool) preg_match( + '/FROM prenotazioni r\s+JOIN utenti u ON r\.utente_id = u\.id.*?ORDER BY r\.queue_position ASC\s+LIMIT 1\s+FOR UPDATE/s', + $pba + ); + $queueReadUsesOffset = (bool) preg_match( + '/FROM prenotazioni r\s+JOIN utenti u ON r\.utente_id = u\.id.*?\bOFFSET\b.*?FOR UPDATE/s', + $pba + ); + check($fifoLockingRead && !$queueReadUsesOffset, 'ReservationManager: queue read locks the first FIFO row without OFFSET'); check(str_contains($pba, "SET stato = 'completata' WHERE id = ? AND stato = 'attiva'"), "ReservationManager: completata claim is guarded by AND stato = 'attiva'"); check((bool) preg_match('/\$claimed\s*!==?\s*1|affected_rows/s', $pba) && str_contains($pba, 'affected_rows'), 'ReservationManager: completata claim checks affected_rows'); // The claim must happen BEFORE the loan is created (match the actual diff --git a/tests/ncip-problemtype-classification.unit.php b/tests/ncip-problemtype-classification.unit.php index 242d18b77..4dd7cf741 100644 --- a/tests/ncip-problemtype-classification.unit.php +++ b/tests/ncip-problemtype-classification.unit.php @@ -69,5 +69,27 @@ "03 isLoanReturned requires stato='restituito', not attivo=0 alone" ); +// 4. A database fault while resolving CheckIn/Renew is retryable. It must not +// collapse into the terminal item-not-checked-out branch used for a genuine +// no-row result. +$checkIn = strstr($src, 'private function handleCheckInItem(', true); +$checkIn = $checkIn === false ? '' : substr($src, strlen($checkIn)); +$renewPos = strpos($checkIn, 'private function handleRenewItem('); +$checkIn = $renewPos === false ? $checkIn : substr($checkIn, 0, $renewPos); +$renew = strstr($src, 'private function handleRenewItem(', true); +$renew = $renew === false ? '' : substr($src, strlen($renew)); +$lookupPos = strpos($renew, 'private function findActiveLoan('); +$renew = $lookupPos === false ? $renew : substr($renew, 0, $lookupPos); +$check( + str_contains($src, 'bool &$databaseError') + && str_contains($src, '$databaseError = true;') + && str_contains($src, "SecureLogger::error('[NcipServer] findActiveLoan failed: '") + && str_contains($checkIn, 'if ($loanLookupFailed)') + && str_contains($checkIn, "'temporary-processing-failure'") + && str_contains($renew, 'if ($loanLookupFailed)') + && str_contains($renew, "'temporary-processing-failure'"), + '04 CheckIn/Renew classify active-loan lookup faults as retryable DB errors' +); + echo "\n{$pass} PASS, {$fail} FAIL\n"; exit($fail === 0 ? 0 : 1); diff --git a/tests/ncip-server.spec.js b/tests/ncip-server.spec.js index 558333f2c..196edda12 100644 --- a/tests/ncip-server.spec.js +++ b/tests/ncip-server.spec.js @@ -23,12 +23,23 @@ * 18. POST /ncip RequestItem (staff auth) → 200 RequestItemResponse * 19. RequestItem creates prestito with origine='ncip' in DB * 20. POST /ncip CancelRequestItem (staff auth) → 200 CancelRequestItemResponse + * 21. Existing partners remain optional metadata (no implicit enforcement) + * 22. CheckIn/Renew reject present-but-invalid UserId values + * 23. CheckIn/Renew reject an ambiguous title when UserId is absent + * 24. A valid FromAgencyId is recorded while UserId selects the right loan + * 25. Checkout skips a stale overdue loan even if its copy status is wrong + * 26. All message handlers accept legacy payloads without the NCIP namespace + * 27. CancelRequestItem rejects a missing UserId when requests are ambiguous + * 28. CancelRequestItem with UserId cancels only that patron's pending request + * 29. CancelRequestItem cannot overwrite a request concurrently approved + * 30. CheckInItem cannot close a loan concurrently reassigned to another user + * 31. RenewItem with UserId cannot renew a concurrently reassigned loan * * Run: /tmp/run-e2e.sh tests/ncip-server.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); -const { execFileSync } = require('child_process'); +const { execFileSync, spawn } = require('child_process'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; const DB_USER = process.env.E2E_DB_USER || ''; @@ -62,6 +73,127 @@ function dbQuery(sql) { }).trim(); } +/** + * Open a real second DB session, apply a change inside a transaction and keep + * its book/loan locks until the test explicitly commits. The READY marker is + * emitted only after every setup statement has executed. + * + * @param {string} setupSql + * @returns {Promise<{child: import('child_process').ChildProcessWithoutNullStreams}>} + */ +function beginHeldTransaction(setupSql) { + return new Promise((resolve, reject) => { + const child = spawn('mysql', [...mysqlArgs('', true), '--unbuffered'], { + env: MYSQL_ENV(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let ready = false; + const timer = setTimeout(() => { + if (!ready) { + child.kill(); + reject(new Error(`Timed out opening held MySQL transaction: ${stderr}`)); + } + }, 10_000); + + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + if (!ready && stdout.split(/\r?\n/).includes('NCIP_RACE_READY')) { + ready = true; + clearTimeout(timer); + resolve({ child }); + } + }); + child.once('error', (error) => { + if (!ready) { + clearTimeout(timer); + reject(error); + } + }); + child.once('exit', (code) => { + if (!ready) { + clearTimeout(timer); + reject(new Error(`Held MySQL transaction exited early (${code}): ${stderr}`)); + } + }); + child.stdin.write( + `SET SESSION innodb_lock_wait_timeout=15;\nSTART TRANSACTION;\n${setupSql}\nSELECT 'NCIP_RACE_READY';\n` + ); + }); +} + +/** + * Commit and close a held MySQL transaction. + * @param {{child: import('child_process').ChildProcessWithoutNullStreams}} session + * @returns {Promise} + */ +function commitHeldTransaction(session) { + return new Promise((resolve, reject) => { + let stderr = ''; + // 'exit' fires once: a child that already died (late SQL error, kill) + // emitted it before this listener registers and the promise would hang + // until the Playwright timeout with no diagnosis. Fail fast instead. + if (session.child.exitCode !== null || session.child.signalCode !== null) { + reject(new Error( + `Held MySQL transaction already exited (${session.child.exitCode ?? session.child.signalCode})` + )); + return; + } + session.child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + session.child.once('exit', (code) => { + if (code === 0) resolve(undefined); + else reject(new Error(`Held MySQL transaction commit failed (${code}): ${stderr}`)); + }); + session.child.once('error', reject); + session.child.stdin.end('COMMIT;\n'); + }); +} + +/** + * Wait until the NCIP HTTP connection is blocked behind the fixture lock. This + * proves the request observed the old committed identity before the competing + * transaction commits, so the test exercises the actual TOCTOU window. + */ +async function waitForNcipLockWait() { + for (let attempt = 0; attempt < 100; attempt++) { + const waiting = parseInt(dbQuery( + `SELECT COUNT(*) FROM INFORMATION_SCHEMA.PROCESSLIST + WHERE ID <> CONNECTION_ID() AND DB = DATABASE() AND COMMAND <> 'Sleep' + AND ( + INFO LIKE '%FROM libri WHERE id%FOR UPDATE%' + OR INFO LIKE '%UPDATE prestiti%annullato%' + )` + ), 10) || 0; + if (waiting > 0) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('NCIP request never reached the expected row-lock wait'); +} + +/** + * Release the competing transaction only after the HTTP request is observably + * waiting on it, then return the completed NCIP response. + * + * @param {{child: import('child_process').ChildProcessWithoutNullStreams}} session + * @param {Promise} responsePromise + */ +async function finishLockedRace(session, responsePromise) { + /** @type {Error|null} */ + let waitError = null; + try { + await waitForNcipLockWait(); + } catch (error) { + waitError = error instanceof Error ? error : new Error(String(error)); + } finally { + await commitHeldTransaction(session); + } + const response = await responsePromise; + if (waitError !== null) throw waitError; + return response; +} + function tableExists(tableName) { const count = dbQuery( `SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES @@ -196,34 +328,74 @@ function checkOutItemXml(itemId, userId) { `; } +/** + * @param {number|string|null} agencyId + * @returns {string} + */ +function initiationHeaderXml(agencyId) { + if (agencyId === null) return ''; + return ` + ${agencyId} + `; +} + +/** + * @param {number|string|null} userId + * @returns {string} + */ +function optionalUserIdXml(userId) { + if (userId === null) return ''; + return `${userId}`; +} + /** Build NCIP CheckInItem XML body. */ -function checkInItemXml(loanId) { +function checkInItemXml(itemId, userId = null, agencyId = null) { return ` + ${initiationHeaderXml(agencyId)} + ${optionalUserIdXml(userId)} - ${loanId} + ${itemId} `; } /** Build NCIP RenewItem XML body. */ -function renewItemXml(loanId) { +function renewItemXml(itemId, userId = null, agencyId = null) { return ` + ${initiationHeaderXml(agencyId)} + ${optionalUserIdXml(userId)} - ${loanId} + ${itemId} `; } +/** Build NCIP CancelRequestItem XML body. */ +function cancelRequestItemXml(itemId, userId = null, agencyId = null) { + return ` + + + ${initiationHeaderXml(agencyId)} + ${optionalUserIdXml(userId)} + + ${itemId} + + +`; +} + function basicAuth(user, pass) { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); } @@ -235,12 +407,20 @@ function ncipPost(request, body, auth = null) { return request.post(`${BASE}/ncip`, { data: body, headers }); } +/** Remove NCIP namespace declarations while leaving the request structure intact. */ +function withoutNcipNamespace(xml) { + return xml + .replace(` xmlns="${NCIP_NS}"`, '') + .replace(`\n ncip:version="http://www.niso.org/schemas/ncip/v2_02/ncip_v2_02.xsd"`, '') + .replace(`\n xmlns:ncip="${NCIP_NS}"`, ''); +} + test.skip( !DB_USER || !DB_NAME || !ADMIN_EMAIL || !ADMIN_PASS, 'Missing E2E env (DB_* and admin credentials)' ); -test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => { +test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (31 tests)', () => { /** @type {number} */ let testBookId = 0; /** @type {number} */ @@ -253,6 +433,69 @@ test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => { let dedicatedRunId = ''; /** Track specific prestiti IDs created during these tests for targeted cleanup */ let createdPrestitiIds = /** @type {number[]} */ ([]); + /** Isolated two-copy title used by the hardening regressions. */ + let hardeningBookId = 0; + /** @type {number[]} */ + let hardeningCopyIds = []; + /** @type {number[]} */ + let hardeningUserIds = []; + /** @type {string[]} */ + let hardeningUserEmails = []; + let hardeningPartnerId = 0; + let hardeningAgencyId = ''; + + /** Remove only loans/transactions belonging to the isolated hardening title. */ + function resetHardeningLoans() { + if (hardeningBookId <= 0) return; + dbQuery( + `DELETE FROM ncip_transactions + WHERE prestito_id IN (SELECT id FROM prestiti WHERE libro_id=${hardeningBookId})` + ); + dbQuery(`DELETE FROM prestiti WHERE libro_id=${hardeningBookId}`); + dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${hardeningBookId}`); + dbQuery(`UPDATE libri SET copie_disponibili=copie_totali WHERE id=${hardeningBookId}`); + } + + /** Create one real open NCIP loan bound to a dedicated physical copy. */ + function createHardeningLoan(userId, copyId) { + dbQuery( + `INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, + stato, origine, attivo, renewals, created_at, updated_at) + VALUES + (${hardeningBookId}, ${copyId}, ${userId}, CURDATE(), + DATE_ADD(CURDATE(), INTERVAL 14 DAY), 'in_corso', 'ncip', 1, 0, NOW(), NOW())` + ); + dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + dbQuery( + `UPDATE libri + SET copie_disponibili=(SELECT COUNT(*) FROM copie WHERE libro_id=${hardeningBookId} AND stato='disponibile') + WHERE id=${hardeningBookId}` + ); + return parseInt(dbQuery( + `SELECT id FROM prestiti + WHERE libro_id=${hardeningBookId} AND copia_id=${copyId} AND origine='ncip' + ORDER BY id DESC LIMIT 1` + )) || 0; + } + + /** Create one pending, copy-less NCIP request for cancellation tests. */ + function createHardeningRequest(userId) { + dbQuery( + `INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, + stato, origine, attivo, renewals, created_at, updated_at) + VALUES + (${hardeningBookId}, NULL, ${userId}, CURDATE(), + DATE_ADD(CURDATE(), INTERVAL 14 DAY), 'pendente', 'ncip', 0, 0, NOW(), NOW())` + ); + return parseInt(dbQuery( + `SELECT id FROM prestiti + WHERE libro_id=${hardeningBookId} AND utente_id=${userId} + AND origine='ncip' AND attivo=0 AND stato='pendente' + ORDER BY id DESC LIMIT 1` + )) || 0; + } test.beforeAll(async ({ browser }) => { await ensureNcipPlugin(browser); @@ -301,6 +544,62 @@ test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => { "SELECT id FROM utenti ORDER BY id LIMIT 1" ); testUserId = parseInt(userRow) || 0; + + // Dedicated fixtures for the partner/UserId hardening checks. Keeping + // them separate from tests 9-20 makes every mutation and cleanup local. + const hardeningTag = dedicatedRunId; + hardeningAgencyId = `E2E-NCIP-AGENCY-${hardeningTag}`; + hardeningUserEmails = [ + `ncip-hardening-a-${hardeningTag}@example.test`, + `ncip-hardening-b-${hardeningTag}@example.test`, + ]; + dbQuery( + `INSERT INTO utenti + (codice_tessera, nome, cognome, email, password, stato, + tipo_utente, email_verificata, privacy_accettata) + VALUES + ('NHA${hardeningTag}', 'NCIP', 'Hardening A', '${hardeningUserEmails[0]}', 'not-used', 'attivo', 'standard', 1, 1), + ('NHB${hardeningTag}', 'NCIP', 'Hardening B', '${hardeningUserEmails[1]}', 'not-used', 'attivo', 'standard', 1, 1)` + ); + hardeningUserIds = hardeningUserEmails.map((email) => parseInt(dbQuery( + `SELECT id FROM utenti WHERE email='${email}' LIMIT 1` + )) || 0); + + dbQuery( + `INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) + VALUES ('NCIP Hardening Book ${hardeningTag}', 2, 2, NOW(), NOW())` + ); + hardeningBookId = parseInt(dbQuery( + `SELECT id FROM libri WHERE titolo='NCIP Hardening Book ${hardeningTag}' ORDER BY id DESC LIMIT 1` + )) || 0; + dbQuery( + `INSERT INTO copie (libro_id, numero_inventario, stato, created_at) + VALUES + (${hardeningBookId}, 'NCIP-HARD-${hardeningTag}-A', 'disponibile', NOW()), + (${hardeningBookId}, 'NCIP-HARD-${hardeningTag}-B', 'disponibile', NOW())` + ); + hardeningCopyIds = ['A', 'B'].map((suffix) => parseInt(dbQuery( + `SELECT id FROM copie WHERE numero_inventario='NCIP-HARD-${hardeningTag}-${suffix}' LIMIT 1` + )) || 0); + + dbQuery( + `INSERT INTO ncip_partners + (code, name, agency_id, endpoint_url, isil, active, created_at, updated_at) + VALUES + ('NCH-${hardeningTag}', 'NCIP Hardening Partner ${hardeningTag}', + '${hardeningAgencyId}', 'https://ncip-hardening.example.test/ncip', + 'IT-NCH-${hardeningTag}', 1, NOW(), NOW())` + ); + hardeningPartnerId = parseInt(dbQuery( + `SELECT id FROM ncip_partners WHERE code='NCH-${hardeningTag}' LIMIT 1` + )) || 0; + + if (hardeningBookId <= 0 + || hardeningCopyIds.some((id) => id <= 0) + || hardeningUserIds.some((id) => id <= 0) + || hardeningPartnerId <= 0) { + throw new Error('NCIP hardening fixture setup failed'); + } }); // ── Test 1: Plugin registration ────────────────────────────────────────── @@ -531,7 +830,427 @@ test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => { expect(text).toContain(`${ncipLoanBookId}`); }); + // ── Tests 21-26: partner attribution + safe loan resolution ───────────── + + test('21. active partner metadata does not enforce FromAgencyId; one unqualified loan remains resolvable', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + + const renewLoanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + const renewRes = await ncipPost(request, renewItemXml(hardeningBookId), auth); + expect(renewRes.status()).toBe(200); + expect(await renewRes.text()).toContain('RenewItemResponse'); + expect(dbQuery( + `SELECT COALESCE(CAST(partner_id AS CHAR), 'NULL') + FROM ncip_transactions + WHERE prestito_id=${renewLoanId} AND message_type='RenewItem' + ORDER BY id DESC LIMIT 1` + )).toBe('NULL'); + + resetHardeningLoans(); + const checkInLoanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + const checkInRes = await ncipPost(request, checkInItemXml(hardeningBookId), auth); + expect(checkInRes.status()).toBe(200); + expect(await checkInRes.text()).toContain('CheckInItemResponse'); + expect(dbQuery(`SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${checkInLoanId}`)) + .toBe('0:restituito'); + }); + + test('22. present malformed/non-positive UserId is invalid-data and never mutates a loan', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const loanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + + const malformedCheckIn = await ncipPost( + request, + checkInItemXml(hardeningBookId, 'not-a-user'), + auth + ); + expect(malformedCheckIn.status()).toBe(200); + const malformedBody = await malformedCheckIn.text(); + expect(malformedBody).toContain('Problem'); + expect(malformedBody).toContain('invalid-data'); + + const nonPositiveRenew = await ncipPost( + request, + renewItemXml(hardeningBookId, 0), + auth + ); + expect(nonPositiveRenew.status()).toBe(200); + const nonPositiveBody = await nonPositiveRenew.text(); + expect(nonPositiveBody).toContain('Problem'); + expect(nonPositiveBody).toContain('invalid-data'); + + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato, ':', renewals) FROM prestiti WHERE id=${loanId}` + )).toBe('1:in_corso:0'); + }); + + test('23. missing UserId rejects ambiguous CheckIn and Renew without touching either loan', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const firstLoanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + const secondLoanId = createHardeningLoan(hardeningUserIds[1], hardeningCopyIds[1]); + + const ambiguousCheckIn = await ncipPost(request, checkInItemXml(hardeningBookId), auth); + expect(ambiguousCheckIn.status()).toBe(200); + const checkInBody = await ambiguousCheckIn.text(); + expect(checkInBody).toContain('invalid-data'); + expect(checkInBody).toContain('Multiple active loans'); + + const ambiguousRenew = await ncipPost(request, renewItemXml(hardeningBookId), auth); + expect(ambiguousRenew.status()).toBe(200); + const renewBody = await ambiguousRenew.text(); + expect(renewBody).toContain('invalid-data'); + expect(renewBody).toContain('Multiple active loans'); + + expect(dbQuery( + `SELECT COUNT(*) FROM prestiti + WHERE id IN (${firstLoanId},${secondLoanId}) + AND attivo=1 AND stato='in_corso' AND renewals=0` + )).toBe('2'); + }); + + test('24. valid UserId selects the right loan and a known FromAgencyId is logged', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const selectedLoanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + const siblingLoanId = createHardeningLoan(hardeningUserIds[1], hardeningCopyIds[1]); + + const res = await ncipPost( + request, + checkInItemXml(hardeningBookId, hardeningUserIds[0], hardeningAgencyId), + auth + ); + expect(res.status()).toBe(200); + expect(await res.text()).toContain('CheckInItemResponse'); + expect(dbQuery(`SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${selectedLoanId}`)) + .toBe('0:restituito'); + expect(dbQuery(`SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${siblingLoanId}`)) + .toBe('1:in_corso'); + expect(dbQuery( + `SELECT partner_id FROM ncip_transactions + WHERE prestito_id=${selectedLoanId} AND message_type='CheckInItem' + ORDER BY id DESC LIMIT 1` + )).toBe(String(hardeningPartnerId)); + }); + + test('25. checkout skips an unreturned date-overdue copy with stale copy status', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + + dbQuery( + `INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, + stato, origine, attivo, renewals, created_at, updated_at) + VALUES + (${hardeningBookId}, ${hardeningCopyIds[0]}, ${hardeningUserIds[0]}, + DATE_SUB(CURDATE(), INTERVAL 30 DAY), DATE_SUB(CURDATE(), INTERVAL 2 DAY), + 'in_corso', 'ncip', 1, 0, NOW(), NOW())` + ); + // Reproduce legacy drift: the copy cache says available although the + // physical loan was never returned. The loan row remains authoritative. + dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${hardeningBookId}`); + + const res = await ncipPost( + request, + checkOutItemXml(hardeningBookId, hardeningUserIds[1]), + auth + ); + expect(res.status()).toBe(200); + expect(await res.text()).toContain('CheckOutItemResponse'); + + const issued = dbQuery( + `SELECT CONCAT(copia_id, ':', stato, ':', attivo) + FROM prestiti + WHERE libro_id=${hardeningBookId} AND utente_id=${hardeningUserIds[1]} + ORDER BY id DESC LIMIT 1` + ); + expect(issued).toBe(`${hardeningCopyIds[1]}:in_corso:1`); + expect(dbQuery( + `SELECT CONCAT(stato, ':', attivo) FROM prestiti + WHERE libro_id=${hardeningBookId} AND utente_id=${hardeningUserIds[0]} + ORDER BY id DESC LIMIT 1` + )).toBe('in_corso:1'); + }); + + test('26. namespace-free payloads work across every NCIP message handler', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + + const lookupItem = await ncipPost(request, withoutNcipNamespace(lookupItemXml(hardeningBookId))); + expect(lookupItem.status()).toBe(200); + expect(await lookupItem.text()).toContain('LookupItemResponse'); + + const lookupUser = await ncipPost( + request, + withoutNcipNamespace(lookupUserXml(hardeningUserIds[0])), + auth + ); + expect(lookupUser.status()).toBe(200); + expect(await lookupUser.text()).toContain('LookupUserResponse'); + + const checkout = await ncipPost( + request, + withoutNcipNamespace(checkOutItemXml(hardeningBookId, hardeningUserIds[0])), + auth + ); + expect(checkout.status()).toBe(200); + expect(await checkout.text()).toContain('CheckOutItemResponse'); + + const renew = await ncipPost( + request, + withoutNcipNamespace(renewItemXml(hardeningBookId, hardeningUserIds[0], hardeningAgencyId)), + auth + ); + expect(renew.status()).toBe(200); + expect(await renew.text()).toContain('RenewItemResponse'); + + const checkin = await ncipPost( + request, + withoutNcipNamespace(checkInItemXml(hardeningBookId, hardeningUserIds[0], hardeningAgencyId)), + auth + ); + expect(checkin.status()).toBe(200); + expect(await checkin.text()).toContain('CheckInItemResponse'); + + const requestBody = withoutNcipNamespace(` + + + ${hardeningUserIds[0]} + ${hardeningBookId} + NSFREE-${dedicatedRunId} + +`); + const requestItem = await ncipPost(request, requestBody, auth); + expect(requestItem.status()).toBe(200); + expect(await requestItem.text()).toContain('RequestItemResponse'); + + const cancelBody = withoutNcipNamespace(` + + + ${hardeningUserIds[0]} + ${hardeningBookId} + +`); + const cancel = await ncipPost(request, cancelBody, auth); + expect(cancel.status()).toBe(200); + expect(await cancel.text()).toContain('CancelRequestItemResponse'); + }); + + test('27. missing or malformed UserId never cancels an ambiguous pending request', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const firstRequestId = createHardeningRequest(hardeningUserIds[0]); + const secondRequestId = createHardeningRequest(hardeningUserIds[1]); + + const ambiguous = await ncipPost( + request, + cancelRequestItemXml(hardeningBookId), + auth + ); + expect(ambiguous.status()).toBe(200); + const ambiguousBody = await ambiguous.text(); + expect(ambiguousBody).toContain('Problem'); + expect(ambiguousBody).toContain('invalid-data'); + expect(ambiguousBody).toContain('Multiple pending ILL requests'); + + const malformed = await ncipPost( + request, + cancelRequestItemXml(hardeningBookId, 'not-a-user'), + auth + ); + expect(malformed.status()).toBe(200); + const malformedBody = await malformed.text(); + expect(malformedBody).toContain('Problem'); + expect(malformedBody).toContain('invalid-data'); + + expect(dbQuery( + `SELECT COUNT(*) FROM prestiti + WHERE id IN (${firstRequestId},${secondRequestId}) + AND origine='ncip' AND attivo=0 AND stato='pendente'` + )).toBe('2'); + }); + + test('28. cancellation targets and reports the resolved pending borrower', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const selectedRequestId = createHardeningRequest(hardeningUserIds[0]); + const siblingRequestId = createHardeningRequest(hardeningUserIds[1]); + + const cancel = await ncipPost( + request, + cancelRequestItemXml(hardeningBookId, hardeningUserIds[0]), + auth + ); + expect(cancel.status()).toBe(200); + expect(await cancel.text()).toContain('CancelRequestItemResponse'); + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${selectedRequestId}` + )).toBe('0:annullato'); + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${siblingRequestId}` + )).toBe('0:pendente'); + expect(dbQuery( + `SELECT COUNT(*) FROM ncip_transactions + WHERE prestito_id=${selectedRequestId} AND message_type='CancelRequestItem' + AND status='success'` + )).toBe('1'); + + const implicitCancel = await ncipPost( + request, + cancelRequestItemXml(hardeningBookId), + auth + ); + expect(implicitCancel.status()).toBe(200); + const implicitBody = await implicitCancel.text(); + expect(implicitBody).toContain('CancelRequestItemResponse'); + expect(implicitBody).toContain( + `${hardeningUserIds[1]}` + ); + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato) FROM prestiti WHERE id=${siblingRequestId}` + )).toBe('0:annullato'); + + // Fail closed on legacy/anomalous pending rows that already own a copy: + // cancelling those requires the full copy-release lifecycle, not the + // copy-less RequestItem transition implemented by this NCIP message. + const unsafeRequestId = createHardeningRequest(hardeningUserIds[1]); + const boundCopyId = hardeningCopyIds[1]; + dbQuery(`UPDATE copie SET stato='prenotato' WHERE id=${boundCopyId}`); + dbQuery(`UPDATE prestiti SET copia_id=${boundCopyId} WHERE id=${unsafeRequestId}`); + const unsafeCancel = await ncipPost( + request, + cancelRequestItemXml(hardeningBookId, hardeningUserIds[1]), + auth + ); + expect(unsafeCancel.status()).toBe(200); + const unsafeBody = await unsafeCancel.text(); + expect(unsafeBody).toContain('Problem'); + expect(unsafeBody).toContain('item-not-checked-out'); + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato, ':', copia_id) + FROM prestiti WHERE id=${unsafeRequestId}` + )).toBe(`0:pendente:${boundCopyId}`); + expect(dbQuery(`SELECT stato FROM copie WHERE id=${boundCopyId}`)).toBe('prenotato'); + }); + + test('29. cancellation never overwrites a request concurrently approved', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const requestId = createHardeningRequest(hardeningUserIds[0]); + const copyId = hardeningCopyIds[0]; + + // Keep the approval uncommitted. CancelRequestItem starts against the + // previously committed pending version and must wait behind this book + // lock; after COMMIT it must see da_ritirare and leave it untouched. + const approval = await beginHeldTransaction( + `SELECT id FROM libri WHERE id=${hardeningBookId} FOR UPDATE; + SELECT id FROM prestiti WHERE id=${requestId} FOR UPDATE; + UPDATE copie SET stato='prenotato' WHERE id=${copyId}; + UPDATE prestiti + SET attivo=1, stato='da_ritirare', copia_id=${copyId}, + pickup_deadline=DATE_ADD(CURDATE(), INTERVAL 3 DAY), updated_at=NOW() + WHERE id=${requestId};` + ); + const response = await finishLockedRace( + approval, + ncipPost( + request, + cancelRequestItemXml(hardeningBookId, hardeningUserIds[0]), + auth + ) + ); + + expect(response.status()).toBe(200); + const body = await response.text(); + expect(body).toContain('Problem'); + expect(body).toContain('item-not-checked-out'); + expect(dbQuery( + `SELECT CONCAT(attivo, ':', stato, ':', copia_id) + FROM prestiti WHERE id=${requestId}` + )).toBe(`1:da_ritirare:${copyId}`); + expect(dbQuery(`SELECT stato FROM copie WHERE id=${copyId}`)).toBe('prenotato'); + }); + + test('30. CheckIn revalidates the resolved user after acquiring the loan lock', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const loanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + + const reassignment = await beginHeldTransaction( + `SELECT id FROM libri WHERE id=${hardeningBookId} FOR UPDATE; + UPDATE prestiti SET utente_id=${hardeningUserIds[1]}, updated_at=NOW() + WHERE id=${loanId};` + ); + const response = await finishLockedRace( + reassignment, + ncipPost( + request, + checkInItemXml(hardeningBookId), + auth + ) + ); + + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Problem'); + expect(dbQuery( + `SELECT CONCAT(utente_id, ':', attivo, ':', stato) + FROM prestiti WHERE id=${loanId}` + )).toBe(`${hardeningUserIds[1]}:1:in_corso`); + expect(dbQuery(`SELECT stato FROM copie WHERE id=${hardeningCopyIds[0]}`)).toBe('prestato'); + }); + + test('31. Renew revalidates the explicit user after acquiring the loan lock', async ({ request }) => { + resetHardeningLoans(); + const auth = basicAuth(ADMIN_EMAIL, ADMIN_PASS); + const loanId = createHardeningLoan(hardeningUserIds[0], hardeningCopyIds[0]); + const originalDue = dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`); + + const reassignment = await beginHeldTransaction( + `SELECT id FROM libri WHERE id=${hardeningBookId} FOR UPDATE; + UPDATE prestiti SET utente_id=${hardeningUserIds[1]}, updated_at=NOW() + WHERE id=${loanId};` + ); + const response = await finishLockedRace( + reassignment, + ncipPost( + request, + renewItemXml(hardeningBookId, hardeningUserIds[0]), + auth + ) + ); + + expect(response.status()).toBe(200); + const body = await response.text(); + expect(body).toContain('Problem'); + expect(body).toContain('item-not-checked-out'); + expect(dbQuery( + `SELECT CONCAT(utente_id, ':', renewals, ':', data_scadenza) + FROM prestiti WHERE id=${loanId}` + )).toBe(`${hardeningUserIds[1]}:0:${originalDue}`); + }); + test.afterAll(async () => { + // Isolated hardening fixtures (FK-safe and independent from tests 1-20). + try { resetHardeningLoans(); } catch { /* best-effort */ } + if (hardeningPartnerId > 0) { + try { dbQuery(`DELETE FROM ncip_transactions WHERE partner_id=${hardeningPartnerId}`); } catch { /* best-effort */ } + try { dbQuery(`DELETE FROM ncip_partners WHERE id=${hardeningPartnerId}`); } catch { /* best-effort */ } + } + if (hardeningBookId > 0) { + try { dbQuery(`DELETE FROM copie WHERE libro_id=${hardeningBookId}`); } catch { /* best-effort */ } + try { dbQuery(`DELETE FROM libri WHERE id=${hardeningBookId}`); } catch { /* best-effort */ } + } + if (hardeningUserIds.length > 0) { + const hardeningUserList = hardeningUserIds.join(','); + try { dbQuery(`DELETE FROM user_sessions WHERE utente_id IN (${hardeningUserList})`); } catch { /* best-effort */ } + try { dbQuery(`DELETE FROM utenti WHERE id IN (${hardeningUserList})`); } catch { /* best-effort */ } + } else if (hardeningUserEmails.length > 0) { + const quotedEmails = hardeningUserEmails.map((email) => `'${email}'`).join(','); + try { dbQuery(`DELETE FROM utenti WHERE email IN (${quotedEmails})`); } catch { /* best-effort */ } + } + // Clean up only the specific prestiti IDs created by these tests. // This avoids accidentally deleting pre-existing NCIP loans for the same book // (e.g. from other test runs that were not fully cleaned up). diff --git a/tests/one-time-form-token.unit.php b/tests/one-time-form-token.unit.php new file mode 100644 index 000000000..cfcdcb34e --- /dev/null +++ b/tests/one-time-form-token.unit.php @@ -0,0 +1,39 @@ +set_charset('utf8mb4'); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +$passed = 0; +$failed = 0; +$check = static function (bool $condition, string $description) use (&$passed, &$failed): void { + echo ($condition ? ' OK ' : ' FAIL ') . $description . PHP_EOL; + $condition ? $passed++ : $failed++; +}; + +$run = bin2hex(random_bytes(5)); +$titlePrefix = "ZZ_366OVERDUE_{$run}_"; +$emailSuffix = "-{$run}@366overdue.test.local"; +$inventoryPrefix = "ZZ366O-{$run}-"; +$sessionBefore = $_SESSION ?? []; +$applicationToday = DateHelper::today(); +DateHelper::synchronizeDatabaseSession($db); +$sessionApplicationToday = (string) $db->query( + 'SELECT @pinakes_application_date' +)->fetch_row()[0]; +$pastStart = (new DateTimeImmutable($applicationToday))->modify('-10 days')->format('Y-m-d'); +$pastDue = (new DateTimeImmutable($applicationToday))->modify('-1 day')->format('Y-m-d'); +$candidateStart = (new DateTimeImmutable($applicationToday))->modify('+1 day')->format('Y-m-d'); +$candidateEnd = (new DateTimeImmutable($applicationToday))->modify('+7 days')->format('Y-m-d'); + +$captureSetting = static function (string $key) use ($db): array { + $stmt = $db->prepare( + "SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = ? LIMIT 1" + ); + $stmt->bind_param('s', $key); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return [ + 'exists' => $row !== null, + 'value' => $row !== null && $row['setting_value'] !== null ? (string) $row['setting_value'] : null, + ]; +}; + +$restoreSetting = static function (string $key, array $original) use ($db): void { + if (!$original['exists']) { + $stmt = $db->prepare( + "DELETE FROM system_settings WHERE category = 'loans' AND setting_key = ?" + ); + $stmt->bind_param('s', $key); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); + return; + } + + $value = $original['value']; + $stmt = $db->prepare( + "INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('loans', ?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)" + ); + $stmt->bind_param('ss', $key, $value); + $stmt->execute(); + $stmt->close(); + ConfigStore::clearCache(); +}; + +$originalMultiplicity = $captureSetting('allow_multiple_loans_same_book'); +$originalMaxLoans = $captureSetting('max_active_loans_per_user'); + +$userSequence = 0; +$makeUser = static function (string $role = 'standard') use ($db, $run, $emailSuffix, &$userSequence): int { + $userSequence++; + $card = 'Z366O' . strtoupper($run) . $userSequence; + $email = "u{$userSequence}{$emailSuffix}"; + $surname = "ZZ366 Overdue {$userSequence}"; + $stmt = $db->prepare( + "INSERT INTO utenti + (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'Test', ?, ?, 'x', 'attivo', ?, 1)" + ); + $stmt->bind_param('ssss', $card, $surname, $email, $role); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; + +$bookSequence = 0; +/** @return array{0:int,1:list,2:list} */ +$makeBook = static function (int $copyCount) use ($db, $titlePrefix, $inventoryPrefix, &$bookSequence): array { + $bookSequence++; + $title = $titlePrefix . $bookSequence; + $stmt = $db->prepare( + 'INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW())' + ); + $stmt->bind_param('sii', $title, $copyCount, $copyCount); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + + $copyIds = []; + $copyCodes = []; + for ($copyNo = 1; $copyNo <= $copyCount; $copyNo++) { + $code = $inventoryPrefix . $bookSequence . '-' . $copyNo; + $stmt = $db->prepare( + "INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')" + ); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + $copyCodes[] = $code; + } + + return [$bookId, $copyIds, $copyCodes]; +}; + +$makeLoan = static function ( + int $bookId, + int $copyId, + int $userId, + string $start, + string $end, + string $state = 'in_corso' +) use ($db): int { + $stmt = $db->prepare( + "INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', 1)" + ); + $stmt->bind_param('iiisss', $bookId, $copyId, $userId, $start, $end, $state); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; + +$callStore = static function ( + int $adminId, + int $userId, + int $bookId, + string $copyCode, + string $start, + string $end +) use ($db) { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $adminId]; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/admin/loans/create') + ->withParsedBody([ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => (string) $userId, + 'libro_id' => (string) $bookId, + 'copy_code' => $copyCode, + 'data_prestito' => $start, + 'data_scadenza' => $end, + 'note' => 'Issue #366 overdue-copy invariant', + 'consegna_immediata' => '1', + 'scarica_pdf' => '0', + ]); + + return (new PrestitiController())->store( + $request, + (new ResponseFactory())->createResponse(), + $db + ); +}; + +$cleanupFixtures = static function () use ($db, $titlePrefix, $emailSuffix): void { + $titleLike = $db->real_escape_string($titlePrefix) . '%'; + $emailLike = $db->real_escape_string('%' . $emailSuffix); + $db->query( + "DELETE n FROM admin_notifications n + JOIN prestiti p ON p.id = n.related_id + JOIN libri l ON l.id = p.libro_id + WHERE l.titolo LIKE '{$titleLike}'" + ); + $db->query("DELETE r FROM prenotazioni r JOIN libri l ON l.id = r.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE p FROM prestiti p JOIN libri l ON l.id = p.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE c FROM copie c JOIN libri l ON l.id = c.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE FROM libri WHERE titolo LIKE '{$titleLike}'"); + $db->query("DELETE FROM utenti WHERE email LIKE '{$emailLike}'"); +}; + +$sandboxSuffix = bin2hex(random_bytes(5)); +$sandboxLoans = "zz366overdue_loans_{$sandboxSuffix}"; +$sandboxCopies = "zz366overdue_copies_{$sandboxSuffix}"; +$sandboxInsertTrigger = "zz366overdue_insert_{$sandboxSuffix}"; +$sandboxUpdateTrigger = "zz366overdue_update_{$sandboxSuffix}"; +$cleanupSandbox = static function () use ( + $db, + $sandboxLoans, + $sandboxCopies, + $sandboxInsertTrigger, + $sandboxUpdateTrigger +): void { + $db->query("DROP TRIGGER IF EXISTS `{$sandboxInsertTrigger}`"); + $db->query("DROP TRIGGER IF EXISTS `{$sandboxUpdateTrigger}`"); + $db->query("DROP TABLE IF EXISTS `{$sandboxLoans}`"); + $db->query("DROP TABLE IF EXISTS `{$sandboxCopies}`"); +}; + +$installSandboxTrigger = static function (string $canonicalName, string $sandboxName) use ( + $db, + $root, + $sandboxLoans, + $sandboxCopies +): void { + $source = (string) file_get_contents($root . '/installer/database/triggers.sql'); + $pattern = '/CREATE TRIGGER `' . preg_quote($canonicalName, '/') . '`.*?END\$\$/s'; + if (!preg_match($pattern, $source, $match)) { + throw new RuntimeException("Cannot extract canonical trigger {$canonicalName}"); + } + $ddl = substr($match[0], 0, -2); + $ddl = str_replace( + [ + "`{$canonicalName}`", + 'ON `prestiti`', + 'FROM prestiti p', + 'FROM copie c', + 'FROM copie WHERE', + ], + [ + "`{$sandboxName}`", + "ON `{$sandboxLoans}`", + "FROM `{$sandboxLoans}` p", + "FROM `{$sandboxCopies}` c", + "FROM `{$sandboxCopies}` WHERE", + ], + $ddl + ); + + $executable = preg_replace('/^\s*--.*$/m', '', $ddl) ?? $ddl; + if (preg_match('/\b(prestiti|copie|prenotazioni|libri)\b/i', $executable, $leftover) === 1) { + throw new RuntimeException("Sandbox trigger still references real table {$leftover[1]}"); + } + $db->query($ddl); +}; + +$cleanupFixtures(); +$cleanupSandbox(); + +try { + $check( + $sessionApplicationToday === $applicationToday, + 'DateHelper binds the configured application date to the database session' + ); + + $staleCommitment = [[ + 'loanId' => 1, + 'copyId' => 10, + 'userId' => 100, + 'startDate' => $pastStart, + 'endDate' => $pastDue, + 'state' => 'in_corso', + ]]; + $check( + LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 2, + 200, + $candidateStart, + $candidateEnd, + $staleCommitment, + $applicationToday + ), + 'date-overdue in_corso is open-ended in the central comparator' + ); + + $futureDue = (new DateTimeImmutable($applicationToday))->modify('+3 days')->format('Y-m-d'); + $futureStart = (new DateTimeImmutable($futureDue))->modify('+1 day')->format('Y-m-d'); + $futureEnd = (new DateTimeImmutable($futureStart))->modify('+5 days')->format('Y-m-d'); + $currentCommitment = [[ + 'loanId' => 3, + 'copyId' => 11, + 'userId' => 101, + 'startDate' => $pastStart, + 'endDate' => $futureDue, + 'state' => 'in_corso', + ]]; + $check( + !LoanMultiplicityPolicy::candidateConflictsWithOpenCommitments( + 4, + 201, + $futureStart, + $futureEnd, + $currentCommitment, + $applicationToday + ), + 'not-yet-due in_corso still permits a disjoint future commitment' + ); + + $settings = new SettingsRepository($db); + $settings->set('loans', 'allow_multiple_loans_same_book', '1'); + $settings->set('loans', 'max_active_loans_per_user', '0'); + + $adminId = $makeUser('admin'); + $holderId = $makeUser(); + $candidateUserId = $makeUser(); + [$bookId, $copyIds, $copyCodes] = $makeBook(2); + $staleLoanId = $makeLoan($bookId, $copyIds[0], $holderId, $pastStart, $pastDue); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copyIds[0]}"); + + $forced = $callStore( + $adminId, + $candidateUserId, + $bookId, + $copyCodes[0], + $candidateStart, + $candidateEnd + ); + $candidateRowsAfterForced = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$candidateUserId}" + )->fetch_row()[0]; + $check( + str_contains($forced->getHeaderLine('Location'), 'error=copy_not_available') + && $candidateRowsAfterForced === 0, + 'forced staff allocation rejects a copy held by stale overdue in_corso' + ); + + $automatic = $callStore( + $adminId, + $candidateUserId, + $bookId, + '', + $candidateStart, + $candidateEnd + ); + $automaticRow = $db->query( + "SELECT id, copia_id FROM prestiti + WHERE libro_id = {$bookId} AND utente_id = {$candidateUserId} + ORDER BY id DESC LIMIT 1" + )->fetch_assoc() ?: []; + $check( + str_contains($automatic->getHeaderLine('Location'), 'created=1') + && (int) ($automaticRow['copia_id'] ?? 0) === $copyIds[1] + && (int) ($automaticRow['id'] ?? 0) !== $staleLoanId, + 'automatic staff allocation skips the overdue copy and selects the free sibling' + ); + + $futureHolderId = $makeUser(); + $futureCandidateId = $makeUser(); + [$futureBookId, $futureCopyIds, $futureCopyCodes] = $makeBook(1); + $futureCurrentStart = (new DateTimeImmutable($applicationToday))->modify('-2 days')->format('Y-m-d'); + $futureCurrentDue = (new DateTimeImmutable($applicationToday))->modify('+2 days')->format('Y-m-d'); + $disjointStart = (new DateTimeImmutable($futureCurrentDue))->modify('+1 day')->format('Y-m-d'); + $disjointEnd = (new DateTimeImmutable($disjointStart))->modify('+5 days')->format('Y-m-d'); + $makeLoan( + $futureBookId, + $futureCopyIds[0], + $futureHolderId, + $futureCurrentStart, + $futureCurrentDue + ); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$futureCopyIds[0]}"); + $futureForced = $callStore( + $adminId, + $futureCandidateId, + $futureBookId, + $futureCopyCodes[0], + $disjointStart, + $disjointEnd + ); + $futureCopyRows = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE copia_id = {$futureCopyIds[0]} AND attivo = 1" + )->fetch_row()[0]; + $check( + str_contains($futureForced->getHeaderLine('Location'), 'created=1') + && $futureCopyRows === 2, + 'forced staff allocation preserves valid scheduling after a not-yet-due loan' + ); + + $expiredCandidateId = $makeUser(); + $expiredRowsBefore = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE libro_id = {$futureBookId} AND utente_id = {$expiredCandidateId}" + )->fetch_row()[0]; + $expiredCreate = $callStore( + $adminId, + $expiredCandidateId, + $futureBookId, + $futureCopyCodes[0], + $pastStart, + $pastDue + ); + $expiredRowsAfter = (int) $db->query( + "SELECT COUNT(*) FROM prestiti WHERE libro_id = {$futureBookId} AND utente_id = {$expiredCandidateId}" + )->fetch_row()[0]; + $check( + str_contains($expiredCreate->getHeaderLine('Location'), 'error=expired_window') + && $expiredRowsAfter === $expiredRowsBefore, + 'staff creation rejects an already-ended active window before copy allocation' + ); + + $dbToday = (string) $db->query('SELECT CURRENT_DATE()')->fetch_row()[0]; + $dbPastStart = (new DateTimeImmutable($dbToday))->modify('-10 days')->format('Y-m-d'); + $dbPastDue = (new DateTimeImmutable($dbToday))->modify('-1 day')->format('Y-m-d'); + $dbCandidateStart = (new DateTimeImmutable($dbToday))->modify('+1 day')->format('Y-m-d'); + $dbCandidateEnd = (new DateTimeImmutable($dbToday))->modify('+7 days')->format('Y-m-d'); + $dbCurrentStart = (new DateTimeImmutable($dbToday))->modify('-2 days')->format('Y-m-d'); + $dbCurrentDue = (new DateTimeImmutable($dbToday))->modify('+2 days')->format('Y-m-d'); + $dbDisjointStart = (new DateTimeImmutable($dbCurrentDue))->modify('+1 day')->format('Y-m-d'); + $dbDisjointEnd = (new DateTimeImmutable($dbDisjointStart))->modify('+5 days')->format('Y-m-d'); + $dbCorrectedPastStart = (new DateTimeImmutable($dbPastStart))->modify('+1 day')->format('Y-m-d'); + + $db->query("CREATE TABLE `{$sandboxCopies}` ( + id INT NOT NULL AUTO_INCREMENT, + libro_id INT NOT NULL, + stato VARCHAR(32) NOT NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB"); + $db->query("CREATE TABLE `{$sandboxLoans}` ( + id INT NOT NULL AUTO_INCREMENT, + libro_id INT NOT NULL, + copia_id INT NULL, + data_prestito DATE NOT NULL, + data_scadenza DATE NOT NULL, + stato VARCHAR(32) NOT NULL, + attivo TINYINT(1) NOT NULL, + PRIMARY KEY (id), + KEY idx_copy (copia_id) + ) ENGINE=InnoDB"); + $db->query("INSERT INTO `{$sandboxCopies}` (libro_id, stato) VALUES + (1, 'prestato'), (1, 'disponibile'), (1, 'prestato'), (1, 'disponibile'), + (1, 'prestato'), (1, 'disponibile'), (1, 'prestato'), (1, 'disponibile'), + (1, 'disponibile'), (1, 'disponibile'), (1, 'disponibile'), (1, 'disponibile'), + (1, 'disponibile'), (1, 'disponibile'), (1, 'disponibile'), (1, 'disponibile'), + (1, 'disponibile'), (1, 'disponibile'), (1, 'disponibile')"); + + $seed = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, ?, ?, ?, ?, 1)" + ); + $seedCopy = 1; + $seedState = 'in_corso'; + $seedStart = $dbPastStart; + $seedEnd = $dbPastDue; + $seed->bind_param('isss', $seedCopy, $seedStart, $seedEnd, $seedState); + $seed->execute(); // id 1: stale holder for INSERT gate + $seedCopy = 3; + $seed->execute(); // id 2: stale holder for UPDATE gate + $seedCopy = 4; + $seedState = 'prenotato'; + $seedStart = $dbCandidateStart; + $seedEnd = $dbCandidateEnd; + $seed->execute(); // id 3: candidate moved onto stale copy 3 + $seedCopy = 5; + $seedState = 'in_corso'; + $seedStart = $dbCurrentStart; + $seedEnd = $dbCurrentDue; + $seed->execute(); // id 4: current holder for disjoint INSERT + $seedCopy = 7; + $seed->execute(); // id 5: current holder for disjoint UPDATE + $seedCopy = 8; + $seedState = 'prenotato'; + $seedStart = $dbDisjointStart; + $seedEnd = $dbDisjointEnd; + $seed->execute(); // id 6: candidate moved onto current copy 7 + $seedCopy = 9; + $seedStart = $dbCandidateStart; + $seedEnd = $dbCandidateEnd; + $seed->execute(); // id 7: future hold blocks a later stale INSERT + $seedCopy = 10; + $seed->execute(); // id 8: future hold blocks a later in_ritardo INSERT + $seedCopy = 11; + $seed->execute(); // id 9: future hold blocks moving a stale candidate here + $seedCopy = 12; + $seedState = 'in_corso'; + $seedStart = $dbPastStart; + $seedEnd = $dbPastDue; + $seed->execute(); // id 10: stale candidate moved onto copy 11 + $seedCopy = 13; + $seedState = 'prenotato'; + $seed->execute(); // id 11: finite past row used for a state-only transition + $seedStart = $dbCandidateStart; + $seedEnd = $dbCandidateEnd; + $seed->execute(); // id 12: future hold beside row 11 before it becomes open-ended + $seedCopy = 14; + $seedState = 'in_corso'; + $seedStart = $dbPastStart; + $seedEnd = $dbPastDue; + $seed->execute(); // id 13: legacy stale predecessor in an existing conflict + $seedState = 'prenotato'; + $seedStart = $dbCandidateStart; + $seedEnd = $dbCandidateEnd; + $seed->execute(); // id 14: legacy successor already sharing copy 14 + $seed->close(); + + $installSandboxTrigger('trg_check_active_prestito_before_insert', $sandboxInsertTrigger); + $installSandboxTrigger('trg_check_active_prestito_before_update', $sandboxUpdateTrigger); + + // An uninitialized/direct SQL connection keeps the database-local fallback. + $db->query('SET @pinakes_application_date = NULL'); + + $insertStaleBlocked = false; + try { + $stmt = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, 1, ?, ?, 'prenotato', 1)" + ); + $stmt->bind_param('ss', $dbCandidateStart, $dbCandidateEnd); + $stmt->execute(); + $stmt->close(); + } catch (mysqli_sql_exception $e) { + $insertStaleBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check($insertStaleBlocked, 'INSERT trigger rejects a new hold after stale overdue in_corso'); + + $updateStaleBlocked = false; + try { + $db->query("UPDATE `{$sandboxLoans}` SET copia_id = 3 WHERE id = 3"); + } catch (mysqli_sql_exception $e) { + $updateStaleBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check($updateStaleBlocked, 'UPDATE trigger rejects moving a hold onto stale overdue in_corso'); + + $insertNewStaleBlocked = false; + try { + $stmt = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, 9, ?, ?, 'in_corso', 1)" + ); + $stmt->bind_param('ss', $dbPastStart, $dbPastDue); + $stmt->execute(); + $stmt->close(); + } catch (mysqli_sql_exception $e) { + $insertNewStaleBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check( + $insertNewStaleBlocked, + 'INSERT trigger rejects stale NEW in_corso even when its nominal dates precede an existing future hold' + ); + + $insertNewOverdueBlocked = false; + try { + $stmt = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, 10, ?, ?, 'in_ritardo', 1)" + ); + $stmt->bind_param('ss', $dbPastStart, $dbPastDue); + $stmt->execute(); + $stmt->close(); + } catch (mysqli_sql_exception $e) { + $insertNewOverdueBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check( + $insertNewOverdueBlocked, + 'INSERT trigger rejects open-ended NEW in_ritardo before an existing future hold' + ); + + $updateNewStaleBlocked = false; + try { + $db->query("UPDATE `{$sandboxLoans}` SET copia_id = 11 WHERE id = 10"); + } catch (mysqli_sql_exception $e) { + $updateNewStaleBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check( + $updateNewStaleBlocked, + 'UPDATE trigger rejects moving an open-ended stale loan before an existing future hold' + ); + + $stateOnlyOpenBlocked = false; + try { + $db->query("UPDATE `{$sandboxLoans}` SET stato = 'in_corso' WHERE id = 11"); + } catch (mysqli_sql_exception $e) { + $stateOnlyOpenBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $check( + $stateOnlyOpenBlocked, + 'UPDATE trigger rejects a state-only transition that makes a past hold open-ended' + ); + + $correctiveLegacyEditSucceeded = true; + try { + $stmt = $db->prepare("UPDATE `{$sandboxLoans}` SET data_prestito = ? WHERE id = 13"); + $stmt->bind_param('s', $dbCorrectedPastStart); + $stmt->execute(); + $stmt->close(); + } catch (mysqli_sql_exception) { + $correctiveLegacyEditSucceeded = false; + } + $check( + $correctiveLegacyEditSucceeded, + 'UPDATE trigger keeps an open-ended legacy predecessor editable inside its pre-existing conflict' + ); + + $insertDisjointSucceeded = true; + try { + $stmt = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, 5, ?, ?, 'prenotato', 1)" + ); + $stmt->bind_param('ss', $dbDisjointStart, $dbDisjointEnd); + $stmt->execute(); + $stmt->close(); + } catch (mysqli_sql_exception) { + $insertDisjointSucceeded = false; + } + $check($insertDisjointSucceeded, 'INSERT trigger allows a future hold after not-yet-due in_corso'); + + $updateDisjointSucceeded = true; + try { + $db->query("UPDATE `{$sandboxLoans}` SET copia_id = 7 WHERE id = 6"); + } catch (mysqli_sql_exception) { + $updateDisjointSucceeded = false; + } + $check($updateDisjointSucceeded, 'UPDATE trigger allows a future hold after not-yet-due in_corso'); + + // Simulate the midnight boundary where the configured application day is + // still yesterday while the MySQL server has already advanced. A loan due + // on the application day is finite for the app and must not be reclassified + // as an open-ended overdue hold by either trigger. + $mismatchApplicationToday = (new DateTimeImmutable($dbToday))->modify('-1 day')->format('Y-m-d'); + $mismatchHolderStart = (new DateTimeImmutable($mismatchApplicationToday))->modify('-5 days')->format('Y-m-d'); + $mismatchCandidateStart = (new DateTimeImmutable($mismatchApplicationToday))->modify('+1 day')->format('Y-m-d'); + $mismatchCandidateEnd = (new DateTimeImmutable($mismatchCandidateStart))->modify('+5 days')->format('Y-m-d'); + $setApplicationDate = $db->prepare('SET @pinakes_application_date = ?'); + $setApplicationDate->bind_param('s', $mismatchApplicationToday); + $setApplicationDate->execute(); + $setApplicationDate->close(); + + $mismatchSeed = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, ?, ?, ?, ?, 1)" + ); + $mismatchCopy = 15; + $mismatchState = 'in_corso'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchHolderStart, + $mismatchApplicationToday, + $mismatchState + ); + $mismatchSeed->execute(); + + $insertUsesApplicationDate = true; + try { + $mismatchState = 'prenotato'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchCandidateStart, + $mismatchCandidateEnd, + $mismatchState + ); + $mismatchSeed->execute(); + } catch (mysqli_sql_exception) { + $insertUsesApplicationDate = false; + } + $check( + $insertUsesApplicationDate, + 'INSERT trigger uses the bound application day when it differs from CURRENT_DATE()' + ); + + $mismatchCopy = 16; + $mismatchState = 'in_corso'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchHolderStart, + $mismatchApplicationToday, + $mismatchState + ); + $mismatchSeed->execute(); + $mismatchCopy = 17; + $mismatchState = 'prenotato'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchCandidateStart, + $mismatchCandidateEnd, + $mismatchState + ); + $mismatchSeed->execute(); + $updateCandidateId = (int) $db->insert_id; + + $updateUsesApplicationDate = true; + try { + $db->query("UPDATE `{$sandboxLoans}` SET copia_id = 16 WHERE id = {$updateCandidateId}"); + } catch (mysqli_sql_exception) { + $updateUsesApplicationDate = false; + } + $check( + $updateUsesApplicationDate, + 'UPDATE trigger uses the bound application day when it differs from CURRENT_DATE()' + ); + + $db->query('SET @pinakes_application_date = NULL'); + $mismatchCopy = 18; + $mismatchState = 'in_corso'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchHolderStart, + $mismatchApplicationToday, + $mismatchState + ); + $mismatchSeed->execute(); + $directSqlFallbackBlocked = false; + try { + $mismatchState = 'prenotato'; + $mismatchSeed->bind_param( + 'isss', + $mismatchCopy, + $mismatchCandidateStart, + $mismatchCandidateEnd, + $mismatchState + ); + $mismatchSeed->execute(); + } catch (mysqli_sql_exception $e) { + $directSqlFallbackBlocked = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $mismatchSeed->close(); + $check( + $directSqlFallbackBlocked, + 'triggers fall back to CURRENT_DATE() when direct SQL leaves the session day unset' + ); + + // Exercise the opposite boundary too: when MySQL is still on yesterday, + // a loan due on the DB day is already stale according to the application. + // The bound day must prevent a permissive (fail-open) direct-SQL reading. + $aheadApplicationToday = (new DateTimeImmutable($dbToday))->modify('+1 day')->format('Y-m-d'); + $aheadHolderStart = (new DateTimeImmutable($dbToday))->modify('-5 days')->format('Y-m-d'); + $aheadCandidateStart = $aheadApplicationToday; + $aheadCandidateEnd = (new DateTimeImmutable($aheadCandidateStart))->modify('+5 days')->format('Y-m-d'); + $setAheadApplicationDate = $db->prepare('SET @pinakes_application_date = ?'); + $setAheadApplicationDate->bind_param('s', $aheadApplicationToday); + $setAheadApplicationDate->execute(); + $setAheadApplicationDate->close(); + + $aheadSeed = $db->prepare( + "INSERT INTO `{$sandboxLoans}` + (libro_id, copia_id, data_prestito, data_scadenza, stato, attivo) + VALUES (1, 19, ?, ?, ?, 1)" + ); + $aheadState = 'in_corso'; + $aheadSeed->bind_param('sss', $aheadHolderStart, $dbToday, $aheadState); + $aheadSeed->execute(); + $aheadDateBlocks = false; + try { + $aheadState = 'prenotato'; + $aheadSeed->bind_param('sss', $aheadCandidateStart, $aheadCandidateEnd, $aheadState); + $aheadSeed->execute(); + } catch (mysqli_sql_exception $e) { + $aheadDateBlocks = str_contains($e->getMessage(), 'Esiste già un prestito attivo'); + } + $aheadSeed->close(); + $check( + $aheadDateBlocks, + 'bound application day prevents fail-open when CURRENT_DATE() is one day behind' + ); + $db->query('SET @pinakes_application_date = NULL'); +} catch (Throwable $e) { + $failed++; + echo ' FAIL exception: ' . $e->getMessage() . PHP_EOL; +} finally { + try { + $db->rollback(); + } catch (Throwable) { + // Best effort: no transaction is expected here. + } + try { + $cleanupSandbox(); + $cleanupFixtures(); + $restoreSetting('allow_multiple_loans_same_book', $originalMultiplicity); + $restoreSetting('max_active_loans_per_user', $originalMaxLoans); + } catch (Throwable $cleanupError) { + $failed++; + echo ' FAIL cleanup: ' . $cleanupError->getMessage() . PHP_EOL; + } + $_SESSION = $sessionBefore; + $db->close(); +} + +echo PHP_EOL . "{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/overdue-copy-read-models-366.unit.php b/tests/overdue-copy-read-models-366.unit.php new file mode 100644 index 000000000..8813e5143 --- /dev/null +++ b/tests/overdue-copy-read-models-366.unit.php @@ -0,0 +1,345 @@ +set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +$passed = 0; +$failed = 0; +$check = static function (bool $condition, string $description) use (&$passed, &$failed): void { + echo ($condition ? ' OK ' : ' FAIL ') . $description . PHP_EOL; + $condition ? $passed++ : $failed++; +}; + +$run = bin2hex(random_bytes(5)); +$titlePrefix = "ZZ_366READ_{$run}_"; +$emailSuffix = "-{$run}@366read.test.local"; +$inventoryPrefix = "ZZ366R-{$run}-"; +$today = DateHelper::today(); +$pastStart = (new DateTimeImmutable($today))->modify('-20 days')->format('Y-m-d'); +$pastDue = (new DateTimeImmutable($today))->modify('-2 days')->format('Y-m-d'); +$futureStart = (new DateTimeImmutable($today))->modify('+2 days')->format('Y-m-d'); +$futureEnd = (new DateTimeImmutable($today))->modify('+8 days')->format('Y-m-d'); + +$userSequence = 0; +$makeUser = static function () use ($db, $run, $emailSuffix, &$userSequence): int { + $userSequence++; + $card = 'Z366R' . strtoupper($run) . $userSequence; + $email = "u{$userSequence}{$emailSuffix}"; + $stmt = $db->prepare( + "INSERT INTO utenti + (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'Test', 'ZZ366 Read', ?, 'x', 'attivo', 'standard', 1)" + ); + $stmt->bind_param('ss', $card, $email); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; + +$bookSequence = 0; +/** @return array{0:int,1:list} */ +$makeBook = static function (int $copyCount) use ($db, $titlePrefix, $inventoryPrefix, &$bookSequence): array { + $bookSequence++; + $title = $titlePrefix . $bookSequence; + $stmt = $db->prepare( + 'INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW())' + ); + $stmt->bind_param('sii', $title, $copyCount, $copyCount); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + + $copyIds = []; + for ($copyNo = 1; $copyNo <= $copyCount; $copyNo++) { + $inventory = $inventoryPrefix . $bookSequence . '-' . $copyNo; + $stmt = $db->prepare( + "INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')" + ); + $stmt->bind_param('is', $bookId, $inventory); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; + +$makeLoan = static function ( + int $bookId, + int $copyId, + int $userId, + string $start, + string $end, + string $state +) use ($db): int { + $stmt = $db->prepare( + "INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', 1)" + ); + $stmt->bind_param('iiisss', $bookId, $copyId, $userId, $start, $end, $state); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; + +$cleanup = static function () use ($db, $titlePrefix, $emailSuffix): void { + $titleLike = $db->real_escape_string($titlePrefix) . '%'; + $emailLike = $db->real_escape_string('%' . $emailSuffix); + $db->query( + "DELETE n FROM admin_notifications n + JOIN prestiti p ON p.id = n.related_id + JOIN libri l ON l.id = p.libro_id + WHERE l.titolo LIKE '{$titleLike}'" + ); + $db->query("DELETE r FROM prenotazioni r JOIN libri l ON l.id = r.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE p FROM prestiti p JOIN libri l ON l.id = p.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE c FROM copie c JOIN libri l ON l.id = c.libro_id WHERE l.titolo LIKE '{$titleLike}'"); + $db->query("DELETE FROM libri WHERE titolo LIKE '{$titleLike}'"); + $db->query("DELETE FROM utenti WHERE email LIKE '{$emailLike}'"); +}; + +$cleanup(); + +try { + $staleUser = $makeUser(); + $futureUser = $makeUser(); + [$overbookedBook, $overbookedCopies] = $makeBook(2); + $staleLoanId = $makeLoan( + $overbookedBook, + $overbookedCopies[0], + $staleUser, + $pastStart, + $pastDue, + 'in_corso' + ); + $futureLoanId = $makeLoan( + $overbookedBook, + $overbookedCopies[1], + $futureUser, + $futureStart, + $futureEnd, + 'prenotato' + ); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$overbookedCopies[0]}"); + $db->query("UPDATE copie SET stato = 'danneggiato' WHERE id = {$overbookedCopies[1]}"); + + $issues = (new DataIntegrity($db))->verifyDataConsistency(); + $foundOverbooking = false; + foreach ($issues as $issue) { + if (($issue['type'] ?? '') === 'overbooked_circulation_period' + && str_contains((string) ($issue['message'] ?? ''), (string) $overbookedBook)) { + $foundOverbooking = true; + break; + } + } + $check( + $foundOverbooking, + 'auditor overlaps a stale in_corso loan with a later commitment open-endedly' + ); + + $currentUser = $makeUser(); + $disjointUser = $makeUser(); + [$disjointBook, $disjointCopies] = $makeBook(1); + $currentDue = (new DateTimeImmutable($today))->modify('+3 days')->format('Y-m-d'); + $disjointStart = (new DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $disjointEnd = (new DateTimeImmutable($disjointStart))->modify('+5 days')->format('Y-m-d'); + $currentLoanId = $makeLoan( + $disjointBook, + $disjointCopies[0], + $currentUser, + $pastStart, + $currentDue, + 'in_corso' + ); + $makeLoan( + $disjointBook, + $disjointCopies[0], + $disjointUser, + $disjointStart, + $disjointEnd, + 'prenotato' + ); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$disjointCopies[0]}"); + + $controlIssues = (new DataIntegrity($db))->verifyDataConsistency(); + $controlOverbooked = false; + foreach ($controlIssues as $issue) { + if (($issue['type'] ?? '') === 'overbooked_circulation_period' + && str_contains((string) ($issue['message'] ?? ''), (string) $disjointBook)) { + $controlOverbooked = true; + break; + } + } + $check( + !$controlOverbooked, + 'auditor preserves disjoint scheduling after a not-yet-due in_corso loan' + ); + + $events = (new DashboardStats($db))->calendarEvents(); + $eventsById = []; + foreach ($events as $event) { + $eventsById[(string) ($event['id'] ?? '')] = $event; + } + $staleEvent = $eventsById['loan_' . $staleLoanId] ?? null; + $currentEvent = $eventsById['loan_' . $currentLoanId] ?? null; + $check( + is_array($staleEvent) + && ($staleEvent['status'] ?? '') === 'in_corso' + && ($staleEvent['end'] ?? '') === $today, + 'dashboard includes stale in_corso and extends its event through application today' + ); + $check( + is_array($currentEvent) && ($currentEvent['end'] ?? '') === $currentDue, + 'dashboard keeps the contractual end for not-yet-due in_corso' + ); + + // Execute the exact occupied-range SELECT embedded in the API route, then + // separately pin its bind order. This covers query behaviour without + // bootstrapping every unrelated web route and middleware in this unit test. + $webSource = (string) file_get_contents($root . '/app/Routes/web.php'); + $sectionStart = strpos($webSource, '// Intervalli occupati'); + $sectionEnd = $sectionStart === false + ? false + : strpos($webSource, '// Anche la coda prenotazioni', $sectionStart); + $apiSection = $sectionStart !== false && $sectionEnd !== false + ? substr($webSource, $sectionStart, $sectionEnd - $sectionStart) + : ''; + $apiSql = ''; + if (preg_match('/\$stmt = \$db->prepare\("(.*?)"\);/s', $apiSection, $match) === 1) { + $apiSql = $match[1]; + } + $check( + $apiSql !== '' + && str_contains($apiSection, "bind_param('si', \$today, \$libroId)"), + 'availability route binds application today before the book id' + ); + + $apiRows = []; + if ($apiSql !== '') { + $stmt = $db->prepare($apiSql); + $stmt->bind_param('si', $today, $overbookedBook); + $stmt->execute(); + $result = $stmt->get_result(); + while ($row = $result->fetch_assoc()) { + $apiRows[] = $row; + } + $stmt->close(); + } + $staleRangeFound = false; + $futureRangePreserved = false; + foreach ($apiRows as $row) { + if (($row['stato'] ?? '') === 'in_corso' + && ($row['data_prestito'] ?? '') === $pastStart + && ($row['occupied_until'] ?? '') === '9999-12-31') { + $staleRangeFound = true; + } + if (($row['stato'] ?? '') === 'prenotato' + && ($row['data_prestito'] ?? '') === $futureStart + && ($row['occupied_until'] ?? '') === $futureEnd) { + $futureRangePreserved = true; + } + } + $check( + $staleRangeFound, + 'availability occupied_ranges exposes stale in_corso as open-ended' + ); + $check( + $futureRangePreserved, + 'availability occupied_ranges preserves a normal future contractual end' + ); + + $calendar = (new ReservationsController($db))->getBookAvailabilityData( + $overbookedBook, + $today, + 14 + ); + $todayAvailability = is_array($calendar) ? ($calendar['by_date'][$today] ?? null) : null; + $check( + is_array($todayAvailability) && (int) ($todayAvailability['available'] ?? -1) === 0, + 'per-day availability keeps a stale in_corso copy occupied today' + ); + $check( + is_array($calendar) && ($calendar['earliest_available'] ?? null) === null, + 'per-day availability does not invent a date beyond the scan horizon' + ); + + $check( + str_contains($webSource, "'first_available' => \$availability['earliest_available'] ?? null") + && str_contains($webSource, "'earliest_available' => \$availability['earliest_available'] ?? null"), + 'availability routes preserve an unknown first date as null' + ); + + // Keep both ids live in the assertions above: this also prevents an + // accidental fixture simplification from leaving the future row untested. + $check($futureLoanId > 0, 'future comparison fixture was persisted'); +} catch (Throwable $e) { + $failed++; + echo ' FAIL exception: ' . $e->getMessage() . PHP_EOL; +} finally { + try { + $db->rollback(); + } catch (Throwable) { + // Best effort: no transaction is expected here. + } + try { + $cleanup(); + } catch (Throwable $cleanupError) { + $failed++; + echo ' FAIL cleanup: ' . $cleanupError->getMessage() . PHP_EOL; + } + $db->close(); +} + +echo PHP_EOL . "{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/pickup-notification-retry.unit.php b/tests/pickup-notification-retry.unit.php new file mode 100644 index 000000000..dc4f1cdf3 --- /dev/null +++ b/tests/pickup-notification-retry.unit.php @@ -0,0 +1,344 @@ +set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — pickup retry test is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +final class ThrowingPickupNotificationService extends NotificationService +{ + public function resolveRecipientLocale(string $email): string + { + throw new RuntimeException('Injected failure after pickup claim'); + } +} + +final class AbaPickupNotificationService extends NotificationService +{ + public function __construct(private mysqli $testDb, private int $testLoanId) + { + parent::__construct($testDb); + } + + public function resolveRecipientLocale(string $email): string + { + // Simulate: claimant A sends; the loan is reassigned/reset; claimant B + // acquires a new token; then A fails late. A must not clear B's claim. + $replacementToken = str_repeat('b', 32); + $attemptedAt = DateHelper::now(); + $stmt = $this->testDb->prepare( + "UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ?" + ); + $stmt->bind_param('ssi', $replacementToken, $attemptedAt, $this->testLoanId); + $stmt->execute(); + $stmt->close(); + throw new RuntimeException('Injected late failure after another claimant took ownership'); + } +} + +$run = bin2hex(random_bytes(6)); +$title = "ZZ_PICKUP_RETRY_{$run}"; +$email = "zz-pickup-retry-{$run}@test.local"; +$inventory = "ZZPR-{$run}"; +$bookId = 0; +$copyId = 0; +$userId = 0; +$replacementUserId = 0; +$loanId = 0; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; +$cleanup = static function () use ($db, &$bookId, &$userId, &$replacementUserId): void { + // All fixture identifiers start at zero, which is not a generated ID. + // Unconditional deletes therefore keep partial-setup cleanup simple and + // let static analysis follow the captured by-reference values correctly. + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM copie WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM libri WHERE id = {$bookId}"); + $db->query("DELETE FROM utenti WHERE id = {$userId}"); + $db->query("DELETE FROM utenti WHERE id = {$replacementUserId}"); +}; + +try { + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, stato) VALUES (?, 1, 0, 'prestato')"); + $stmt->bind_param('s', $title); + $stmt->execute(); + $bookId = (int) $db->insert_id; + $stmt->close(); + + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'prenotato')"); + $stmt->bind_param('is', $bookId, $inventory); + $stmt->execute(); + $copyId = (int) $db->insert_id; + $stmt->close(); + + $card = 'ZZPR' . strtoupper($run); + $password = password_hash('PickupRetry!', PASSWORD_DEFAULT); + $stmt = $db->prepare( + "INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) + VALUES (?, 'Pickup', 'Retry', ?, ?, 'standard', 'attivo', 1)" + ); + $stmt->bind_param('sss', $card, $email, $password); + $stmt->execute(); + $userId = (int) $db->insert_id; + $stmt->close(); + + $replacementCard = 'ZZPRR' . strtoupper($run); + $replacementEmail = "zz-pickup-retry-replacement-{$run}@test.local"; + $stmt = $db->prepare( + "INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) + VALUES (?, 'Pickup', 'Replacement', ?, ?, 'standard', 'attivo', 1)" + ); + $stmt->bind_param('sss', $replacementCard, $replacementEmail, $password); + $stmt->execute(); + $replacementUserId = (int) $db->insert_id; + $stmt->close(); + + $start = DateHelper::today(); + $end = (new DateTimeImmutable($start))->modify('+14 days')->format('Y-m-d'); + $deadline = (new DateTimeImmutable($start))->modify('+3 days')->format('Y-m-d'); + $stmt = $db->prepare( + "INSERT INTO prestiti + (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, + attivo, pickup_deadline, pickup_notification_sent) + VALUES (?, ?, ?, ?, ?, 'da_ritirare', 'diretto', 1, ?, 0)" + ); + $stmt->bind_param('iiisss', $bookId, $copyId, $userId, $start, $end, $deadline); + $stmt->execute(); + $loanId = (int) $db->insert_id; + $stmt->close(); + + $sent = (new ThrowingPickupNotificationService($db))->sendPickupReadyNotification($loanId); + $retryState = $db->query( + "SELECT pickup_notification_sent, pickup_notification_claim_token, + pickup_notification_last_attempt_at + FROM prestiti WHERE id = {$loanId}" + )->fetch_assoc(); + $check($sent === false, 'an exception after the atomic claim is reported as a failed send'); + $check( + (int) ($retryState['pickup_notification_sent'] ?? 1) === 0 + && ($retryState['pickup_notification_claim_token'] ?? null) === null, + 'the catch path releases only its owned claim so the next sweep can retry' + ); + $check( + !empty($retryState['pickup_notification_last_attempt_at']), + 'a failed attempt retains its timestamp for fair retry ordering' + ); + + // A worker that dies after claiming leaves sent=1 plus a token behind. Once + // the 15-minute lease has expired, a later sender must be able to take over; + // the injected failure then proves that the new owner can release its claim. + $orphanToken = str_repeat('c', 32); + // Come per la lease viva più sotto: parti dal riferimento UTC di + // produzione, non da DateHelper::now() (fuso applicativo). + $orphanAttemptedAt = (new DateTimeImmutable(PickupNotificationSchema::claimLeaseWindow()['attemptedAt'])) + ->modify('-1 day') + ->format('Y-m-d H:i:s'); + $stmt = $db->prepare( + "UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ?" + ); + $stmt->bind_param('ssi', $orphanToken, $orphanAttemptedAt, $loanId); + $stmt->execute(); + $stmt->close(); + + $orphanRetrySent = (new ThrowingPickupNotificationService($db))->sendPickupReadyNotification($loanId); + $orphanState = $db->query( + "SELECT pickup_notification_sent, pickup_notification_claim_token, + pickup_notification_last_attempt_at + FROM prestiti WHERE id = {$loanId}" + )->fetch_assoc(); + $check($orphanRetrySent === false, 'a stale orphan claim is acquired before the injected delivery failure'); + $check( + (int) ($orphanState['pickup_notification_sent'] ?? 1) === 0 + && ($orphanState['pickup_notification_claim_token'] ?? null) === null + && (string) ($orphanState['pickup_notification_last_attempt_at'] ?? '') > $orphanAttemptedAt, + 'an expired orphan lease is recoverable and its new owner can release it' + ); + + // Conversely, a live claimant must retain ownership until its lease expires. + $liveToken = str_repeat('d', 32); + // Stesso riferimento UTC della produzione: claimLeaseWindow() calcola + // staleBefore in UTC, mentre DateHelper::now() usa il fuso applicativo + // (Europe/Rome, +1/+2h). Con quello scarto il check "lease ancora viva" + // passerebbe anche con una lease molto più corta o azzerata. + $liveAttemptedAt = PickupNotificationSchema::claimLeaseWindow()['attemptedAt']; + $stmt = $db->prepare( + "UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ?" + ); + $stmt->bind_param('ssi', $liveToken, $liveAttemptedAt, $loanId); + $stmt->execute(); + $stmt->close(); + + $liveRetrySent = (new ThrowingPickupNotificationService($db))->sendPickupReadyNotification($loanId); + $liveState = $db->query( + "SELECT pickup_notification_sent, pickup_notification_claim_token, + pickup_notification_last_attempt_at + FROM prestiti WHERE id = {$loanId}" + )->fetch_assoc(); + $check($liveRetrySent === false, 'a send cannot steal a claim whose lease is still live'); + $check( + (int) ($liveState['pickup_notification_sent'] ?? 0) === 1 + && ($liveState['pickup_notification_claim_token'] ?? '') === $liveToken + && ($liveState['pickup_notification_last_attempt_at'] ?? '') === $liveAttemptedAt, + 'the live owner token and lease timestamp remain unchanged' + ); + + $db->query("UPDATE prestiti SET pickup_notification_sent = 0, pickup_notification_claim_token = NULL WHERE id = {$loanId}"); + $abaSent = (new AbaPickupNotificationService($db, $loanId))->sendPickupReadyNotification($loanId); + $abaState = $db->query( + "SELECT pickup_notification_sent, pickup_notification_claim_token + FROM prestiti WHERE id = {$loanId}" + )->fetch_assoc(); + $check($abaSent === false, 'late failure after reassignment is reported without masking ownership'); + $check( + (int) ($abaState['pickup_notification_sent'] ?? 0) === 1 + && ($abaState['pickup_notification_claim_token'] ?? '') === str_repeat('b', 32), + 'an old sender cannot release a newer claimant (ABA ownership guard)' + ); + + // Simulate a reassignment committed after a worker read the old recipient + // but before it attempted the atomic claim. The user predicate must make the + // stale claim a no-op, leaving the replacement recipient retryable. + $staleRecipientUserId = $userId; + $stmt = $db->prepare( + "UPDATE prestiti + SET utente_id = ?, pickup_notification_sent = 0, + pickup_notification_claim_token = NULL, + pickup_notification_last_attempt_at = NULL + WHERE id = ?" + ); + $stmt->bind_param('ii', $replacementUserId, $loanId); + $stmt->execute(); + $stmt->close(); + + $staleClaimToken = str_repeat('e', 32); + $staleClaimAttemptedAt = DateHelper::now(); + $staleBefore = (new DateTimeImmutable($staleClaimAttemptedAt)) + ->modify('-15 minutes') + ->format('Y-m-d H:i:s'); + $stmt = $db->prepare( + "UPDATE prestiti + SET pickup_notification_sent = 1, + pickup_notification_claim_token = ?, + pickup_notification_last_attempt_at = ? + WHERE id = ? AND utente_id = ? + AND attivo = 1 AND stato = 'da_ritirare' + AND ( + pickup_notification_sent IS NULL + OR pickup_notification_sent = 0 + OR ( + pickup_notification_sent = 1 + AND pickup_notification_claim_token IS NOT NULL + AND pickup_notification_last_attempt_at < ? + ) + )" + ); + $stmt->bind_param('ssiis', $staleClaimToken, $staleClaimAttemptedAt, $loanId, $staleRecipientUserId, $staleBefore); + $stmt->execute(); + $staleClaimRows = $stmt->affected_rows; + $stmt->close(); + $reassignedState = $db->query( + "SELECT utente_id, pickup_notification_sent, pickup_notification_claim_token + FROM prestiti WHERE id = {$loanId}" + )->fetch_assoc(); + $check( + $staleClaimRows === 0 + && (int) ($reassignedState['utente_id'] ?? 0) === $replacementUserId + && (int) ($reassignedState['pickup_notification_sent'] ?? 1) === 0 + && ($reassignedState['pickup_notification_claim_token'] ?? null) === null, + 'a claim bound to the stale user cannot consume a reassigned recipient retry' + ); + + $fixedStaleBefore = '2026-08-21 10:00:00'; + $check( + PickupNotificationSchema::isClaimLive(str_repeat('f', 32), '2026-08-21 10:00:00', $fixedStaleBefore) + && !PickupNotificationSchema::isClaimLive(str_repeat('f', 32), '2026-08-21 09:59:59', $fixedStaleBefore) + && !PickupNotificationSchema::isClaimLive(null, null, $fixedStaleBefore), + 'reassignment blocks a live pickup claim but can clear an expired orphan lease' + ); + $check( + PickupNotificationSchema::isClaimLive(str_repeat('f', 32), null, $fixedStaleBefore), + 'a malformed token without a timestamp fails closed during reassignment' + ); + + $cron = (string) file_get_contents($root . '/cron/automatic-notifications.php'); + $check( + str_contains($cron, '$notificationService->retryUnsentPickupNotifications()'), + 'the hourly notification cron invokes the pickup retry sweep' + ); + $notificationSource = (string) file_get_contents($root . '/app/Support/NotificationService.php'); + $claimStart = strpos($notificationSource, '$claimStmt = $this->db->prepare'); + $claimEnd = $claimStart === false ? false : strpos($notificationSource, '$claimStmt->close()', $claimStart); + $claimSource = $claimStart === false || $claimEnd === false + ? '' + : substr($notificationSource, $claimStart, $claimEnd - $claimStart); + $check( + str_contains($claimSource, 'WHERE id = ? AND utente_id = ?') + && str_contains($claimSource, '$recipientUserId') + && str_contains($claimSource, "bind_param('ssiis'"), + 'the production pickup claim binds its atomic guard to the recipient user' + ); + $check( + str_contains($notificationSource, 'pickup_notification_last_attempt_at IS NULL DESC') + && str_contains($notificationSource, 'pickup_notification_last_attempt_at ASC'), + 'retry batches prioritize never-attempted rows before recurring poison recipients' + ); +} catch (Throwable $e) { + $failed++; + fwrite(STDERR, "UNCAUGHT TEST ERROR: {$e->getMessage()}\n{$e->getTraceAsString()}\n"); +} finally { + try { + $cleanup(); + } catch (Throwable $e) { + $failed++; + fwrite(STDERR, "CLEANUP ERROR: {$e->getMessage()}\n"); + } + $db->close(); +} + +echo "\n{$passed} PASS, {$failed} FAIL\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/pickup-ready-copy-free-366.unit.php b/tests/pickup-ready-copy-free-366.unit.php index c02b83a02..8216a3ff0 100644 --- a/tests/pickup-ready-copy-free-366.unit.php +++ b/tests/pickup-ready-copy-free-366.unit.php @@ -23,9 +23,9 @@ * pickup_deadline. * 4. Multi-copy: 2 copies, 1 out overdue -> the reservation on the free * copy IS promoted (multi-copy behaviour preserved). - * 5. Multi-copy, reservation pinned to the copy that is still out -> stays - * 'prenotato' even though the book-level count has room; promotes after - * that copy is returned. + * 5. Multi-copy, reservation pinned to the copy that is still out -> moves + * atomically to a free sibling copy and promotes without waiting for the + * original item to return. * * Drives the real MaintenanceService::activateScheduledLoans() against the * live DB. It asserts only on rows it creates (titles ZZ_366_%, users zz366-%) @@ -84,6 +84,11 @@ function prcfenv(string $path): array exit(0); } $db->set_charset('utf8mb4'); +// Production writers bind the application-local date on every +// connection (container/cron/scripts bootstrap); the circulation +// triggers otherwise fall back to the database's UTC CURRENT_DATE(), +// which disagrees with app.timezone between 22:00 and 24:00 UTC. +\App\Support\DateHelper::synchronizeDatabaseSession($db); $TESTNO = 0; $failed = 0; @@ -104,6 +109,20 @@ function check(bool $cond, string $desc): void $today = \App\Support\DateHelper::today(); $d = static fn (int $offsetDays): string => date('Y-m-d', strtotime($today . ($offsetDays >= 0 ? " +{$offsetDays} days" : ' ' . $offsetDays . ' days'))); +$withDatabaseDate = static function (string $date, callable $callback) use ($db): mixed { + $safeDate = $db->real_escape_string($date); + $db->query("SET timestamp = UNIX_TIMESTAMP('{$safeDate} 12:00:00')"); + // The circulation triggers read the connection-bound application date + // before falling back to CURRENT_DATE(), so warping the clock must warp + // the bound date too — and restore the real application day afterwards. + $db->query("SET @pinakes_application_date = '{$safeDate}'"); + try { + return $callback(); + } finally { + $db->query('SET timestamp = 0'); + \App\Support\DateHelper::synchronizeDatabaseSession($db); + } +}; /* -------------------------------- helpers -------------------------------- */ @@ -156,7 +175,7 @@ function check(bool $cond, string $desc): void }; $loanRow = static function (int $loanId) use ($db): array { - $stmt = $db->prepare("SELECT stato, pickup_deadline FROM prestiti WHERE id = ?"); + $stmt = $db->prepare("SELECT stato, pickup_deadline, copia_id FROM prestiti WHERE id = ?"); $stmt->bind_param('i', $loanId); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc() ?: []; @@ -237,21 +256,33 @@ function check(bool $cond, string $desc): void /* ---- Scenario 3: multi-copy but pinned to the copy still out -------- */ [$book3, $copies3] = $mkBook('pinned-copy', 2); [$c3a, $c3b] = $copies3; - $prev3 = $mkLoan($book3, $c3a, $borrower, 'in_corso', $d(-30), $d(-5)); + // The successor was scheduled while its predecessor was still on time; + // the physical conflict appears only later when the copy is not returned. + [$prev3, $res3] = $withDatabaseDate( + $d(-5), + static function () use ($mkLoan, $book3, $c3a, $borrower, $reserver, $d): array { + $previous = $mkLoan($book3, $c3a, $borrower, 'in_corso', $d(-30), $d(-5)); + $scheduled = $mkLoan($book3, $c3a, $reserver, 'prenotato', $d(-1), $d(20)); + return [$previous, $scheduled]; + } + ); $setCopyState($c3a, 'prestato'); - // Reservation pinned to the very copy that is still out (non-overlapping - // window, so the DB trigger allows the row — the #366 blind spot). - $res3 = $mkLoan($book3, $c3a, $reserver, 'prenotato', $d(-1), $d(20)); $svc->activateScheduledLoans(); $row = $loanRow($res3); - check(($row['stato'] ?? '') === 'prenotato', "reservation pinned to the out copy stays 'prenotato' even with book-level room"); + check( + ($row['stato'] ?? '') === 'da_ritirare' && (int) ($row['copia_id'] ?? 0) === $c3b, + 'reservation pinned to the out copy moves to the free sibling and becomes ready' + ); $returnLoan($prev3, $c3a); - $setCopyState($c3a, 'prenotato'); // copy back on the shelf, held for the reservation + $setCopyState($c3a, 'disponibile'); $svc->activateScheduledLoans(); $row = $loanRow($res3); - check(($row['stato'] ?? '') === 'da_ritirare', "pinned copy returned: reservation promotes to 'da_ritirare'"); + check( + ($row['stato'] ?? '') === 'da_ritirare' && (int) ($row['copia_id'] ?? 0) === $c3b, + 'returning the original copy does not move or duplicate the already-ready loan' + ); } catch (\Throwable $e) { $cleanup(); diff --git a/tests/related-books-responsive-278.spec.js b/tests/related-books-responsive-278.spec.js index 7c4879a1f..29e9725d8 100644 --- a/tests/related-books-responsive-278.spec.js +++ b/tests/related-books-responsive-278.spec.js @@ -7,7 +7,10 @@ * - the card is capped to a book-sane width on desktop (never full-column); * - NO size inversion across the 768px boundary (widening must not shrink * the card within the same column count); - * - responsive column count grows 1 → 2 → 3 with the viewport; + * - layout mode matches the viewport: below 1024px the section is a + * single-row horizontal snap-scroll strip (all cards on the strip, + * scrollable when they overflow); from 1024px up it is a centred grid + * showing at most 4 columns in the single visible row; * - the row is grouped/centred (bounded width) on ultra-wide screens. * * Book detail is public — no login needed. The spec finds a book that actually @@ -38,17 +41,22 @@ function dbQuery(sql) { return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); } -/** A book whose author has ≥2 catalogued books → getRelatedBooks() returns rows. */ +/** + * A book whose author has ≥2 catalogued books → getRelatedBooks() returns rows. + * Deterministic pick (ORDER BY, skip other specs' ZZ* fixture books) so the + * measured page doesn't change with the physical row order of the catalog. + */ function findBookWithRelated() { const row = dbQuery( "SELECT la.libro_id FROM libri_autori la " + "JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL " + - "WHERE la.ruolo = 'principale' " + + "WHERE la.ruolo = 'principale' AND l.titolo NOT LIKE 'ZZ%' " + "AND la.autore_id IN (" + " SELECT autore_id FROM libri_autori la2 " + " JOIN libri l2 ON l2.id = la2.libro_id AND l2.deleted_at IS NULL " + - " WHERE la2.ruolo = 'principale' GROUP BY autore_id HAVING COUNT(DISTINCT la2.libro_id) >= 2" + - ") LIMIT 1" + " WHERE la2.ruolo = 'principale' AND l2.titolo NOT LIKE 'ZZ%' " + + " GROUP BY autore_id HAVING COUNT(DISTINCT la2.libro_id) >= 2" + + ") ORDER BY la.libro_id LIMIT 1" ); return parseInt(row, 10) || 0; } @@ -65,12 +73,20 @@ async function measure(page, bookId, width) { const top0 = Math.round(rects[0].top); const perRow = rects.filter((r) => Math.abs(Math.round(r.top) - top0) < 5).length; const wrap = document.querySelector('.related-books-wrap'); + const grid = document.querySelector('.related-books-grid'); + const gridStyle = grid ? getComputedStyle(grid) : null; + const columnGap = gridStyle ? parseFloat(gridStyle.columnGap || '0') || 0 : 0; return { present: true, count: cards.length, cardW: Math.round(rects[0].width), perRow, wrapW: wrap ? Math.round(wrap.getBoundingClientRect().width) : null, + mode: gridStyle ? gridStyle.display : null, + overflowX: gridStyle ? gridStyle.overflowX : null, + clientW: grid ? grid.clientWidth : null, + contentW: grid ? Math.round(rects.reduce((sum, rect) => sum + rect.width, 0) + columnGap * Math.max(0, rects.length - 1)) : null, + scrollable: grid ? grid.scrollWidth > grid.clientWidth + 5 : false, }; }); } @@ -110,23 +126,39 @@ test.describe.serial('Related books — responsive layout (#278)', () => { expect(above.cardW, 'widening past 768px must not shrink the card').toBeGreaterThanOrEqual(below.cardW - 1); }); - test('column count grows with the viewport (1 → 2 → 3)', async ({ page }) => { + test('layout mode matches the viewport (snap strip < 1024px, grid above)', async ({ page }) => { test.skip(bookId === 0, 'no book with related books'); - const narrow = await measure(page, bookId, 480); // col-12 → 1 per row - const mid = await measure(page, bookId, 700); // col-sm-6 → 2 per row - const wide = await measure(page, bookId, 1400); // col-lg-4 → up to 3 per row - expect(narrow.perRow).toBe(1); - expect(mid.perRow).toBeLessThanOrEqual(2); - // wide shows as many columns as there are cards, up to 3. + // Below 1024px the section is a horizontal snap-scroll strip: every + // card sits on the single strip row (never wraps) and the container + // permits horizontal scrolling. Actual overflow is asserted only when + // the rendered cards genuinely exceed the container — the strip itself + // must always allow it. + for (const w of [480, 700]) { + const m = await measure(page, bookId, w); + expect(m.mode, `layout mode at ${w}px`).toBe('flex'); + expect(m.perRow, `strip must keep all cards on one row at ${w}px`).toBe(m.count); + expect(m.overflowX, `strip must permit horizontal scrolling at ${w}px`).toMatch(/auto|scroll/); + if (m.contentW > m.clientW + 5) { + expect(m.scrollable, `overflowing strip must be scrollable at ${w}px`).toBe(true); + } + } + // From 1024px up it is a centred grid: as many columns as there are + // cards, capped at 4 in the single visible row (extras are clipped). + const wide = await measure(page, bookId, 1400); + expect(wide.mode, 'layout mode at 1400px').toBe('grid'); expect(wide.perRow).toBeGreaterThanOrEqual(Math.min(3, wide.count)); + expect(wide.perRow).toBeLessThanOrEqual(4); }); test('row is grouped/centred (bounded) on ultra-wide screens', async ({ page }) => { test.skip(bookId === 0, 'no book with related books'); const m = await measure(page, bookId, 2560); - // The .related-books-wrap caps the row so the cards don't spread across - // the whole 2560px container. + // The .related-books-wrap caps the row (max-width: 1320px — widened + // from the old ~960px 3-card cap to fit 4+ columns) so the cards don't + // spread across the whole 2560px container (~92vw ≈ 2355px). The wrap + // is a block element, so its width is count-independent: assert the + // CSS cap, with a small rounding margin. expect(m.wrapW, 'wrap width on 2560px').not.toBeNull(); - expect(m.wrapW).toBeLessThanOrEqual(1000); + expect(m.wrapW).toBeLessThanOrEqual(1330); }); }); diff --git a/tests/review-eligibility-in-ritardo.unit.php b/tests/review-eligibility-in-ritardo.unit.php index 6b7877a81..a8049ca97 100644 --- a/tests/review-eligibility-in-ritardo.unit.php +++ b/tests/review-eligibility-in-ritardo.unit.php @@ -45,6 +45,11 @@ ? new mysqli(null, $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', 0, $socket) : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', (int) ($env['DB_PORT'] ?? 3306)); $db->set_charset('utf8mb4'); + // Production writers bind the application-local date on every + // connection (container/cron/scripts bootstrap); the circulation + // triggers otherwise fall back to the database's UTC CURRENT_DATE(), + // which disagrees with app.timezone between 22:00 and 24:00 UTC. + \App\Support\DateHelper::synchronizeDatabaseSession($db); } catch (\Throwable $e) { fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); exit(1); @@ -169,9 +174,9 @@ // The NCIP active-loan lookup (findActiveLoan, used by CheckInItem/RenewItem) // is the mirror that must keep 'in_ritardo': an overdue NCIP loan still holds // the book. `[^)]*?` tolerates the `AND attivo = 1` that sits between the -// origine filter and the stato IN(...) clause. (findNcipLoan is a DIFFERENT -// method — it cancels a still-'pendente' request, so it correctly omits -// 'in_ritardo'.) +// origine filter and the stato IN(...) clause. +// (cancelPendingNcipRequest is a DIFFERENT method — it cancels a still- +// 'pendente' request, so it correctly omits 'in_ritardo'.) $ncipOk = (bool) preg_match( "/origine\\s*=\\s*'ncip'[^)]*?stato\\s+IN\\s*\\([^)]*'in_ritardo'[^)]*\\)/", $ncip diff --git a/tests/zap-pii-filter.test.sh b/tests/zap-pii-filter.test.sh new file mode 100755 index 000000000..cce4b00ec --- /dev/null +++ b/tests/zap-pii-filter.test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +FILTER_FILE="$ROOT_DIR/scripts/ci-zap-blocking-alerts.jq" +PUBLIC_EXAMPLES_FILTER="$ROOT_DIR/scripts/ci-zap-public-isbn-examples.jq" +CHECK_SCRIPT="$ROOT_DIR/scripts/ci-check-zap-report.sh" +PASS_COUNT=0 +TEST_CONTEXT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/pinakes-zap-test.XXXXXX") +IDENTIFIERS_FILE="$TEST_CONTEXT_DIR/identifiers.json" +PUBLIC_URLS_FILE="$TEST_CONTEXT_DIR/public-urls.json" +PUBLIC_EXAMPLES_FILE="$TEST_CONTEXT_DIR/public-examples.json" +REPORT_FILE="$TEST_CONTEXT_DIR/report.json" +trap 'rm -f "$IDENTIFIERS_FILE" "$PUBLIC_URLS_FILE" "$PUBLIC_EXAMPLES_FILE" "$REPORT_FILE"; rmdir "$TEST_CONTEXT_DIR"' EXIT + +printf '%s\n' '["5634784354285"]' > "$IDENTIFIERS_FILE" +printf '%s\n' '["http://localhost:8081/jane-austen/pride-and-prejudice/123","http://localhost:8081/fr/jane-austen/orgueil-et-prejuges/123"]' > "$PUBLIC_URLS_FILE" +jq -f "$PUBLIC_EXAMPLES_FILTER" "$ROOT_DIR/locale/it_IT.json" > "$PUBLIC_EXAMPLES_FILE" + +expected_public_examples='["9788842935780","9788845292866"]' +if [[ "$(jq -c . "$PUBLIC_EXAMPLES_FILE")" != "$expected_public_examples" ]]; then + echo 'FAIL: canonical public ISBN examples changed unexpectedly' >&2 + exit 1 +fi +PASS_COUNT=$((PASS_COUNT + 1)) + +extractor_fixture='{ + "es. 9788842935780": "public ISBN example", + "support": "public card example 4222222222222", + "tracking": "prefix 978884529286612345", + "es. 4222222222222": "not an ISBN namespace" +}' +if [[ "$(jq -c -f "$PUBLIC_EXAMPLES_FILTER" <<<"$extractor_fixture")" != '["9788842935780"]' ]]; then + echo 'FAIL: public example extractor admitted an unrelated number or substring' >&2 + exit 1 +fi +PASS_COUNT=$((PASS_COUNT + 1)) + +invalid_checksum_fixture='{"es. 9788842935781":"invalid checksum"}' +if jq -f "$PUBLIC_EXAMPLES_FILTER" <<<"$invalid_checksum_fixture" >/dev/null 2>&1; then + echo 'FAIL: public example extractor accepted an invalid ISBN-13 checksum' >&2 + exit 1 +fi +PASS_COUNT=$((PASS_COUNT + 1)) + +make_alert() { + local uri=$1 + local evidence=${2:-5634784354285} + local method=${3:-GET} + local plugin_id=${4:-10062} + + jq -cn \ + --arg uri "$uri" \ + --arg evidence "$evidence" \ + --arg method "$method" \ + --arg plugin_id "$plugin_id" \ + '{riskcode:"3", riskdesc:"High", alert:"fixture", pluginid:$plugin_id, instances:[{uri:$uri, evidence:$evidence, method:$method, param:"", attack:""}]}' +} + +blocking_count() { + local document + document=$(jq -cn --argjson alert "$1" '{site:[{alerts:[$alert]}]}') + filter_count "$document" +} + +filter_count() { + jq -c . <<<"$1" \ + | jq -s \ + --slurpfile catalogue_identifiers "$IDENTIFIERS_FILE" \ + --slurpfile public_catalogue_urls "$PUBLIC_URLS_FILE" \ + --slurpfile public_example_identifiers "$PUBLIC_EXAMPLES_FILE" \ + -f "$FILTER_FILE" \ + | jq 'length' +} + +assert_allowed() { + local label=$1 + local alert=$2 + local actual + actual=$(blocking_count "$alert") + if [[ "$actual" != "0" ]]; then + echo "FAIL: expected allowlisted alert: $label" >&2 + exit 1 + fi + PASS_COUNT=$((PASS_COUNT + 1)) +} + +assert_blocked() { + local label=$1 + local alert=$2 + local actual + actual=$(blocking_count "$alert") + if [[ "$actual" != "1" ]]; then + echo "FAIL: expected blocking alert: $label" >&2 + exit 1 + fi + PASS_COUNT=$((PASS_COUNT + 1)) +} + +assert_rejected_report() { + local label=$1 + local document=$2 + if filter_count "$document" >/dev/null 2>&1; then + echo "FAIL: expected malformed report rejection: $label" >&2 + exit 1 + fi + PASS_COUNT=$((PASS_COUNT + 1)) +} + +# Every localized catalogue route is part of the explicit bibliographic +# allowlist, both at the root and behind its locale prefix. +ROUTE_KEYS=(catalog catalog_legacy book book_legacy author publisher genre api_catalog api_book) +routes_matrix='' +for routes_file in "$ROOT_DIR"/locale/routes_*.json; do + locale=$(basename "$routes_file" | sed -E 's/^routes_([a-z]{2})_[A-Z]{2}\.json$/\1/') + rows=$(jq -r --arg locale "$locale" \ + '["catalog", "catalog_legacy", "book", "book_legacy", "author", "publisher", "genre", "api_catalog", "api_book"][] as $key | [$locale, $key, .[$key]] | @tsv' \ + "$routes_file") + routes_matrix+="${routes_matrix:+$'\n'}$rows" +done + +expected_routes=$((5 * ${#ROUTE_KEYS[@]})) +actual_routes=$(wc -l <<<"$routes_matrix" | tr -d ' ') +if [[ "$actual_routes" != "$expected_routes" ]]; then + echo "FAIL: expected $expected_routes localized route rows, got $actual_routes" >&2 + exit 1 +fi + +while IFS=$'\t' read -r locale key route; do + case "$key" in + catalog|catalog_legacy|api_catalog) suffix='?fixture=1' ;; + book) suffix='/123/example-title' ;; + book_legacy) suffix='?id=123' ;; + author|publisher|genre) suffix='/Fixture+Name' ;; + api_book) suffix='/123/availability' ;; + *) echo "FAIL: unsupported route key: $key" >&2; exit 1 ;; + esac + if [[ "$key" == 'api_book' ]]; then + # This endpoint emits dates/booleans, not bibliographic identifiers; + # keep it outside the PII exception even though it is a book API. + assert_blocked "$locale$route" "$(make_alert "http://localhost:8081$route$suffix")" + assert_blocked "$locale/$locale$route" "$(make_alert "http://localhost:8081/$locale$route$suffix")" + else + assert_allowed "$locale$route" "$(make_alert "http://localhost:8081$route$suffix")" + assert_allowed "$locale/$locale$route" "$(make_alert "http://localhost:8081/$locale$route$suffix")" + fi +done <<<"$routes_matrix" + +assert_allowed 'BIBFRAME book API' "$(make_alert 'http://localhost:8081/api/bibframe/book/123')" +assert_allowed 'localized BIBFRAME book API' "$(make_alert 'http://localhost:8081/da/api/bibframe/book/123')" +assert_allowed 'BIBFRAME Work API' "$(make_alert 'http://localhost:8081/api/bibframe/book/123/work')" +assert_allowed 'BIBFRAME Instance API' "$(make_alert 'http://localhost:8081/api/bibframe/book/123/instance')" +assert_allowed 'BIBFRAME Instance identifier' "$(make_alert 'http://localhost:8081/id/instance/123')" +assert_allowed 'RDA Manifestation endpoint' "$(make_alert 'http://localhost:8081/libri/123.rda.json')" +assert_allowed 'real Danish publisher finding' "$(make_alert 'http://localhost:8081/da/forlag/CSV+Publisher')" +assert_allowed 'canonical book route' "$(make_alert 'http://localhost:8081/jane-austen/pride-and-prejudice/123')" +assert_allowed 'localized canonical book route' "$(make_alert 'http://localhost:8081/fr/jane-austen/orgueil-et-prejuges/123')" +assert_allowed 'shipped ISBN example on themed 404' "$(make_alert 'http://localhost:8081/en/register' '9788842935780')" +assert_allowed 'second shipped ISBN example on an ordinary page' "$(make_alert 'http://localhost:8081/accedi' '9788845292866')" + +assert_blocked '15-digit evidence' "$(make_alert 'http://localhost:8081/da/forlag/Test' '123456789012345')" +assert_blocked '16-digit evidence' "$(make_alert 'http://localhost:8081/da/forlag/Test' '4111111111111111')" +assert_blocked 'POST request' "$(make_alert 'http://localhost:8081/da/forlag/Test' '5634784354285' 'POST')" +assert_blocked 'admin books route' "$(make_alert 'http://localhost:8081/admin/books/123')" +assert_blocked 'admin Italian books route' "$(make_alert 'http://localhost:8081/admin/libri/123')" +assert_blocked 'admin publishers route' "$(make_alert 'http://localhost:8081/admin/editori/123')" +assert_blocked 'near-match route' "$(make_alert 'http://localhost:8081/da/forlaget/Test')" +assert_blocked 'unknown locale' "$(make_alert 'http://localhost:8081/xx/forlag/Test')" +assert_blocked 'nested publisher path' "$(make_alert 'http://localhost:8081/da/forlag/account/payment')" +assert_blocked 'nonexistent API descendant' "$(make_alert 'http://localhost:8081/api/book/42/private-payment-data')" +assert_blocked 'nonexistent catalogue descendant' "$(make_alert 'http://localhost:8081/catalog/not-an-app-route')" +assert_blocked 'external host' "$(make_alert 'https://example.com/da/forlag/Test')" +assert_blocked 'shipped example on external host' "$(make_alert 'https://example.com/en/register' '9788842935780')" +assert_blocked 'reserved admin canonical shape' "$(make_alert 'http://localhost:8081/admin/settings/123')" +assert_blocked 'localized reserved admin canonical shape' "$(make_alert 'http://localhost:8081/da/admin/settings/123')" +assert_blocked 'reserved API canonical shape' "$(make_alert 'http://localhost:8081/api/private/123')" +assert_blocked 'loan details are not a canonical URL' "$(make_alert 'http://localhost:8081/prestiti/dettagli/123')" +assert_blocked 'unregistered canonical-shaped URL' "$(make_alert 'http://localhost:8081/private/payment/123')" +assert_blocked 'unknown identifier on catalogue page' "$(make_alert 'http://localhost:8081/da/forlag/Test' '9781234567897')" +assert_blocked 'unknown 13-digit value on ordinary page' "$(make_alert 'http://localhost:8081/en/register' '9781234567897')" +assert_blocked 'another ZAP rule' "$(make_alert 'http://localhost:8081/da/forlag/Test' '5634784354285' 'GET' '10020')" + +mixed_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +mixed_alert=$(jq -c '.instances += [{uri:"http://localhost:8081/admin/settings", evidence:"5634784354285", method:"GET", param:"", attack:""}]' <<<"$mixed_alert") +assert_blocked 'mixed safe and unsafe instances' "$mixed_alert" + +empty_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +empty_alert=$(jq -c '.instances = []' <<<"$empty_alert") +assert_blocked 'empty instances array' "$empty_alert" + +param_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +param_alert=$(jq -c '.instances[0].param = "card"' <<<"$param_alert") +assert_blocked 'parameter evidence' "$param_alert" + +attack_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +attack_alert=$(jq -c '.instances[0].attack = "5634784354285"' <<<"$attack_alert") +assert_blocked 'attack payload evidence' "$attack_alert" + +missing_param_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +missing_param_alert=$(jq -c 'del(.instances[0].param)' <<<"$missing_param_alert") +assert_blocked 'missing parameter field' "$missing_param_alert" + +null_param_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +null_param_alert=$(jq -c '.instances[0].param = null' <<<"$null_param_alert") +assert_blocked 'null parameter field' "$null_param_alert" + +missing_attack_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +missing_attack_alert=$(jq -c 'del(.instances[0].attack)' <<<"$missing_attack_alert") +assert_blocked 'missing attack field' "$missing_attack_alert" + +null_attack_alert=$(make_alert 'http://localhost:8081/da/forlag/Test') +null_attack_alert=$(jq -c '.instances[0].attack = null' <<<"$null_attack_alert") +assert_blocked 'null attack field' "$null_attack_alert" + +assert_rejected_report 'missing site' '{}' +assert_rejected_report 'empty site array' '{"site":[]}' +assert_rejected_report 'missing alerts array' '{"site":[{}]}' +assert_rejected_report 'alerts has wrong type' '{"site":[{"alerts":null}]}' +assert_rejected_report 'site has wrong type' '{"site":{}}' +assert_rejected_report 'null alert' '{"site":[{"alerts":[null]}]}' +assert_rejected_report 'missing riskcode' '{"site":[{"alerts":[{}]}]}' +assert_rejected_report 'null riskcode' '{"site":[{"alerts":[{"riskcode":null}]}]}' +assert_rejected_report 'non-numeric riskcode' '{"site":[{"alerts":[{"riskcode":"High"}]}]}' +assert_rejected_report 'negative riskcode' '{"site":[{"alerts":[{"riskcode":-1}]}]}' +assert_rejected_report 'fractional riskcode' '{"site":[{"alerts":[{"riskcode":0.5}]}]}' +assert_rejected_report 'zero-padded riskcode' '{"site":[{"alerts":[{"riskcode":"01"}]}]}' +assert_rejected_report 'NaN riskcode' '{"site":[{"alerts":[{"riskcode":NaN}]}]}' +assert_rejected_report 'multiple JSON documents' $'{"site":[{"alerts":[]}]}\n{"site":[{"alerts":[{"riskcode":"3"}]}]}' + +# Exercise the exact workflow wrapper, not only the jq policy in isolation. +printf '%s\n' '{"site":[{"alerts":[]}]}' > "$REPORT_FILE" +bash "$CHECK_SCRIPT" "$REPORT_FILE" "$IDENTIFIERS_FILE" "$PUBLIC_URLS_FILE" "$PUBLIC_EXAMPLES_FILE" >/dev/null +PASS_COUNT=$((PASS_COUNT + 1)) +printf '%s\n' '{}' > "$REPORT_FILE" +if bash "$CHECK_SCRIPT" "$REPORT_FILE" "$IDENTIFIERS_FILE" "$PUBLIC_URLS_FILE" "$PUBLIC_EXAMPLES_FILE" >/dev/null 2>&1; then + echo 'FAIL: workflow wrapper accepted an invalid report' >&2 + exit 1 +fi +PASS_COUNT=$((PASS_COUNT + 1)) +printf '%s\n%s\n' '{"site":[{"alerts":[]}]}' '{"site":[{"alerts":[]}]}' > "$REPORT_FILE" +if bash "$CHECK_SCRIPT" "$REPORT_FILE" "$IDENTIFIERS_FILE" "$PUBLIC_URLS_FILE" "$PUBLIC_EXAMPLES_FILE" >/dev/null 2>&1; then + echo 'FAIL: workflow wrapper accepted multiple JSON documents' >&2 + exit 1 +fi +PASS_COUNT=$((PASS_COUNT + 1)) + +echo "ZAP PII filter tests passed: $PASS_COUNT" diff --git a/version.json b/version.json index 1eb817688..afb6f850a 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.7.63", + "version": "0.7.64", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }