diff --git a/.github/workflows/ci-browser-security.yml b/.github/workflows/ci-browser-security.yml index 054da3054..d284a9209 100644 --- a/.github/workflows/ci-browser-security.yml +++ b/.github/workflows/ci-browser-security.yml @@ -160,9 +160,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 +211,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/CHANGELOG.md b/CHANGELOG.md index 3c6673b4e..6ea68e361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 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. + ## [0.7.63] - 2026-08-20 ### Fixed diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index 0479fe004..a8765709c 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -267,20 +267,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 +279,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,7 +356,7 @@ 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)) { $existingCopyStmt = $db->prepare(" SELECT c.id FROM copie c WHERE c.id = ? @@ -409,6 +401,17 @@ 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 @@ -421,7 +424,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R ) LIMIT 1 "); - $overlapStmt->bind_param('iss', $libroId, $dataScadenza, $dataPrestito); + $overlapStmt->bind_param('iiiiss', $libroId, $libroId, $utenteId, $loanId, $dataScadenza, $dataPrestito); $overlapStmt->execute(); $overlapResult = $overlapStmt->get_result(); $selectedCopy = $overlapResult ? $overlapResult->fetch_assoc() : null; @@ -433,6 +436,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(); diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index fa2079490..36cab5137 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -172,7 +172,9 @@ 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")); ob_start(); @@ -291,24 +293,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 +379,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 +391,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 +408,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) { @@ -473,6 +474,16 @@ 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 @@ -485,7 +496,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('iiiss', $libro_id, $libro_id, $utente_id, $data_scadenza, $data_prestito); $overlapStmt->execute(); $overlapResult = $overlapStmt->get_result(); $selectedCopy = $overlapResult ? $overlapResult->fetch_assoc() : null; @@ -498,10 +509,17 @@ 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(" @@ -673,13 +691,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'; @@ -882,21 +903,22 @@ 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. + $multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($db); + if ($multiplicityPolicy->hasBlockingLoan( + $libroId, + $newUserId, + $locked['copia_id'] !== null, + $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); } 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/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/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php index c9dc21b6a..945f2bbdb 100644 --- a/app/Services/ReservationReassignmentService.php +++ b/app/Services/ReservationReassignmentService.php @@ -130,35 +130,6 @@ private function rollbackIfOwned(bool $ownTransaction): void */ public function reassignOnNewCopy(int $libroId, int $newCopiaId): void { - // 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,56 +138,128 @@ 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. + $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')) + ORDER BY p.created_at ASC, p.id ASC + "); + $prefilterStmt->bind_param('i', $libroId); + $prefilterStmt->execute(); + $prefilterResult = $prefilterStmt->get_result(); + $candidateIds = []; + while ($row = $prefilterResult->fetch_assoc()) { + $candidateIds[] = (int) $row['id']; + } + $prefilterStmt->close(); + if ($candidateIds === []) { + $this->rollbackIfOwned($ownTransaction); + return; + } + + // 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') ) + 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); + $candidateTypes = str_repeat('i', count($candidateParams)); + $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; } - $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 + ); - if (!$copyStatus || !in_array($copyStatus['stato'], ['disponibile', 'prenotato'], true)) { + // 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(); + + $targetCopyState = $copiesById[$newCopiaId] ?? null; + if ($targetCopyState === null || !in_array($targetCopyState, ['disponibile', 'prenotato'], true)) { $this->rollbackIfOwned($ownTransaction); return; } - // 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 + )) { + $reservation = $candidate; + break; + } + } + if ($reservation === null) { $this->rollbackIfOwned($ownTransaction); return; } @@ -318,6 +361,7 @@ public function reassignOnCopyLost(int $copiaId): void $libroId, $excludedCopies, $reservationId, + (int) $reservation['utente_id'], $resStart, $resEnd ); @@ -338,7 +382,7 @@ 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 ( (attivo = 1 AND stato IN ('prenotato','da_ritirare')) @@ -356,6 +400,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); @@ -540,6 +592,7 @@ private function findAvailableCopyExcluding( int $libroId, array $excludeCopiaIds, int $reservationId, + int $userId, string $startDate, string $endDate ): ?int @@ -549,6 +602,18 @@ 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 @@ -562,8 +627,8 @@ private function findAvailableCopyExcluding( ) ) "; - $params = [$libroId, $reservationId, $endDate, $startDate]; - $types = 'iiss'; + $params = [$libroId, $libroId, $userId, $reservationId, $reservationId, $endDate, $startDate]; + $types = 'iiiiiss'; if (!empty($excludeCopiaIds)) { $placeholders = implode(',', array_fill(0, count($excludeCopiaIds), '?')); diff --git a/app/Support/LoanMultiplicityPolicy.php b/app/Support/LoanMultiplicityPolicy.php new file mode 100644 index 000000000..cae14deb0 --- /dev/null +++ b/app/Support/LoanMultiplicityPolicy.php @@ -0,0 +1,231 @@ +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. + * + * @param list $commitments + */ + public static function candidateConflictsWithOpenCommitments( + int $candidateLoanId, + int $candidateUserId, + string $candidateStartDate, + string $candidateEndDate, + array $commitments + ): bool { + foreach ($commitments as $commitment) { + if ($commitment['loanId'] === $candidateLoanId) { + continue; + } + + if ($commitment['userId'] === $candidateUserId) { + return true; + } + + $startsBeforeCandidateEnds = $commitment['startDate'] <= $candidateEndDate; + $endsAfterCandidateStarts = $commitment['state'] === 'in_ritardo' + || $commitment['endDate'] >= $candidateStartDate; + if ($startsBeforeCandidateEnds && $endsAfterCandidateStarts) { + return true; + } + } + + return false; + } +} diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index ded5dca0f..793e08b68 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -525,7 +525,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 +573,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 +588,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 +637,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); @@ -674,6 +685,18 @@ public function activateScheduledLoans(): int 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 +710,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(); diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index 18f99308f..b89314625 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -16,6 +16,7 @@ $meUserId = (int) ($meUserId ?? 0); $meUserName = (string) ($meUserName ?? ''); $defaultLoanDays = max(1, (int) ($defaultLoanDays ?? 30)); +$allowMultipleLoansSameBook = (bool) ($allowMultipleLoansSameBook ?? false); $csrf = Csrf::ensureToken(); // Get locale from session (same as frontend/layout.php) @@ -117,7 +118,9 @@ class="ml-auto inline-flex items-center px-3 py-2 bg-red-600 hover:bg-red-700 te -
+ @@ -176,6 +179,11 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te

+ +

+ +

+ @@ -249,7 +257,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. --> @@ -957,6 +965,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/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/docs/settings.MD b/docs/settings.MD index a90d4df72..fa7c0dc3c 100644 --- a/docs/settings.MD +++ b/docs/settings.MD @@ -234,9 +234,12 @@ Imposti le regole globali dei prestiti (categoria setting `loans`): | `pickup_expiry_days` | Giorni entro cui ritirare un prestito approvato prima della scadenza ritiro | 3 | 1 … 365 | | `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/locale/da_DK.json b/locale/da_DK.json index 0cf15f9ef..b72a59c19 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6875,5 +6875,11 @@ "%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." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 716676e5e..539e25536 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6875,5 +6875,11 @@ "%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." } diff --git a/locale/en_US.json b/locale/en_US.json index d0d215ae9..0d145d0bb 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6875,5 +6875,11 @@ "%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." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 595bc47c4..f0ac49782 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6875,5 +6875,11 @@ "%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." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 0444f8a9d..09359a9fa 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6875,5 +6875,11 @@ "%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." } 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-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/tests/multiple-copy-loans-238.spec.js b/tests/multiple-copy-loans-238.spec.js new file mode 100644 index 000000000..6b1672e36 --- /dev/null +++ b/tests/multiple-copy-loans-238.spec.js @@ -0,0 +1,371 @@ +// @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 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 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..8f28bfa63 --- /dev/null +++ b/tests/multiple-copy-loans-238.unit.php @@ -0,0 +1,1149 @@ +set_charset('utf8mb4'); +} 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 +) use ($db, $today, $due) { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $adminId]; + $body = [ + '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' + ); + + // 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' + ); + + $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/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/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" }