From 97415dc988d0aef0a8c9917c4e38973ef18f7414 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 18 Jul 2026 05:29:12 +0200 Subject: [PATCH 1/3] fix(seed): make the settings seed non-destructive on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every system_settings INSERT in the installer's data_{it,en,de,fr}.sql used ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), ..., which forces setting_value back to the (usually empty) seed value on any re-run — so a re-seed wiped admin-configured values (social links incl. Telegram, sharing providers, loan limits). Dropped setting_value from every such ON DUPLICATE clause: a fresh install still INSERTs the seed value, but a re-seed now PRESERVES whatever the admin set, only refreshing the description/updated_at metadata. This matches the project's own INSERT-IGNORE seeding principle (never overwrite admin-customised rows). The languages table's ON DUPLICATE is untouched — its columns are ships-with metadata that should refresh. Verified by loading schema + the modified seed into a scratch DB (0 errors, six socials seeded) and re-seeding after configuring social_telegram — the value survives. New tests/seed-idempotency.unit.php locks the contract (static: no destructive setting_value upsert in any locale; behavioural: re-seed preserves an admin value while a fresh key still takes the seed default). 12 checks. Addresses the CodeRabbit #260 re-seed finding at the root (which CodeRabbit withdrew as fresh-install-only, but a non-destructive seed is strictly safer). --- installer/database/data_de_DE.sql | 15 ++-- installer/database/data_en_US.sql | 11 ++- installer/database/data_fr_FR.sql | 15 ++-- installer/database/data_it_IT.sql | 11 ++- tests/seed-idempotency.unit.php | 118 ++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 tests/seed-idempotency.unit.php diff --git a/installer/database/data_de_DE.sql b/installer/database/data_de_DE.sql index 61b21f8e..6610274e 100644 --- a/installer/database/data_de_DE.sql +++ b/installer/database/data_de_DE.sql @@ -188,31 +188,31 @@ INSERT IGNORE INTO `generi` VALUES (181,'Oratorium',NULL,'2025-10-20 16:20:00',' INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('cms', 'events_page_enabled', '1', 'Veranstaltungsseite im Frontend aktivieren oder deaktivieren') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('system', 'catalogue_mode', '0', 'Nur-Katalog-Modus - deaktiviert Ausleihen, Vormerkungen und Wunschliste') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Tage zum Abholen einer genehmigten Ausleihe bevor sie verfällt') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'loan_duration_days', '30', 'Standardausleihfrist in Tagen') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_active_loans_per_user', '0', 'Maximale Anzahl aktiver Ausleihen pro Benutzer (0 = kein Limit)') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +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 setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_loan_duration_days', '90', 'Maximal anforderbare Ausleihdauer in Tagen') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); -- ============================================================================ -- System Settings - Complete default configuration @@ -298,7 +298,6 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc -- Social sharing settings ('sharing', 'enabled_providers', 'facebook,x,whatsapp,email', 'Aktivierte Social-Sharing-Anbieter auf der Buchdetailseite', NOW()) ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index 359af76e..701f8ce7 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -194,23 +194,23 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Days to pick up an approved loan before it expires') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'loan_duration_days', '30', 'Default loan duration in days') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_active_loans_per_user', '0', 'Maximum active loans per user (0 = no limit)') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +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 setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_loan_duration_days', '90', 'Maximum requestable loan duration in days') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); -- ============================================================================ -- System Settings - Complete default configuration @@ -296,7 +296,6 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc -- Social sharing settings ('sharing', 'enabled_providers', 'facebook,x,whatsapp,email', 'Enabled social sharing providers on book detail page', NOW()) ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_fr_FR.sql b/installer/database/data_fr_FR.sql index fd013cbe..b9aae268 100644 --- a/installer/database/data_fr_FR.sql +++ b/installer/database/data_fr_FR.sql @@ -188,31 +188,31 @@ INSERT IGNORE INTO `generi` VALUES (181,'Oratorio',NULL,'2025-10-20 16:20:00','2 INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('cms', 'events_page_enabled', '1', 'Activer ou désactiver la page événements dans le frontend') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('system', 'catalogue_mode', '0', 'Mode catalogue uniquement - désactive les emprunts, réservations et liste de souhaits') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Jours pour récupérer un emprunt approuvé avant expiration') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'loan_duration_days', '30', 'Durée d\'emprunt par défaut en jours') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_active_loans_per_user', '0', 'Nombre maximal d\'emprunts actifs par utilisateur (0 = aucune limite)') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +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 setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_loan_duration_days', '90', 'Durée maximale demandable pour un emprunt en jours') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); -- ============================================================================ -- System Settings - Complete default configuration @@ -298,7 +298,6 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc -- Social sharing settings ('sharing', 'enabled_providers', 'facebook,x,whatsapp,email', 'Fournisseurs de partage social activés sur la page de détail du livre', NOW()) ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index f5433f73..fdd5573e 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -11,23 +11,23 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Giorni per ritirare un prestito approvato prima che scada') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'loan_duration_days', '30', 'Durata predefinita di un prestito in giorni') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_active_loans_per_user', '0', 'Numero massimo di prestiti attivi per utente (0 = nessun limite)') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +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 setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'max_loan_duration_days', '90', 'Durata massima richiedibile per un prestito in giorni') -ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `generi` VALUES (1,'Prosa',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL); INSERT INTO `generi` VALUES (2,'Poesia',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL); @@ -349,7 +349,6 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc -- Social sharing settings ('sharing', 'enabled_providers', 'facebook,x,whatsapp,email', 'Provider di condivisione social abilitati nella pagina dettaglio libro', NOW()) ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), description = VALUES(description), updated_at = NOW(); diff --git a/tests/seed-idempotency.unit.php b/tests/seed-idempotency.unit.php new file mode 100644 index 00000000..5e9afabb --- /dev/null +++ b/tests/seed-idempotency.unit.php @@ -0,0 +1,118 @@ +set_charset('utf8mb4'); +} catch (\Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — the behavioural section is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$T = 'zz_seed_idem'; +$cleanup = static fn () => $db->query("DROP TABLE IF EXISTS {$T}"); +try { + $cleanup(); + $db->query( + "CREATE TABLE {$T} ( + category VARCHAR(50) NOT NULL, + setting_key VARCHAR(100) NOT NULL, + setting_value TEXT, + description VARCHAR(255), + updated_at TIMESTAMP NULL, + UNIQUE KEY uq (category, setting_key) + ) ENGINE=InnoDB" + ); + + // The exact non-destructive shape the seed now uses. + $seed = static fn (string $value, string $desc): bool => $db->query( + "INSERT INTO {$T} (category, setting_key, setting_value, description) VALUES + ('app', 'social_telegram', '{$value}', '{$desc}') + ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW()" + ); + + // First install: empty seed value lands. + $seed('', 'Telegram channel/group URL'); + $row = $db->query("SELECT setting_value, description FROM {$T} WHERE setting_key='social_telegram'")->fetch_assoc(); + $check($row['setting_value'] === '', 'fresh seed inserts the (empty) default value'); + + // Admin configures it. + $db->query("UPDATE {$T} SET setting_value='https://t.me/mylibrary' WHERE setting_key='social_telegram'"); + + // Re-seed (the destructive scenario): value MUST survive, description may refresh. + $seed('', 'Telegram channel/group URL (v2)'); + $row = $db->query("SELECT setting_value, description FROM {$T} WHERE setting_key='social_telegram'")->fetch_assoc(); + $check($row['setting_value'] === 'https://t.me/mylibrary', 're-seed PRESERVES the admin-configured value'); + $check($row['description'] === 'Telegram channel/group URL (v2)', 're-seed still refreshes the description metadata'); + + // A brand-new key on re-seed is still inserted (fresh-install path intact). + $db->query( + "INSERT INTO {$T} (category, setting_key, setting_value, description) VALUES + ('app', 'social_newthing', 'seedval', 'New') + ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW()" + ); + $row = $db->query("SELECT setting_value FROM {$T} WHERE setting_key='social_newthing'")->fetch_assoc(); + $check($row['setting_value'] === 'seedval', 'a new key still receives its seed value'); +} finally { + $cleanup(); + $db->close(); +} + +echo "\n" . ($fail === 0 ? "ALL {$pass} PASS\n" : "{$pass} PASS, {$fail} FAIL\n"); +exit($fail === 0 ? 0 : 1); From 09dcc3350db10ee79f777543bc4b72893438ca10 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 18 Jul 2026 11:17:07 +0200 Subject: [PATCH 2/3] fix(seed): make every system_settings INSERT re-runnable and non-destructive The events_page_enabled and catalogue_mode seed rows shipped as plain INSERTs without an ON DUPLICATE handler, so a re-seed aborted on the duplicate key before reaching the settings whose upsert this branch had already made non-destructive. Add ON DUPLICATE KEY UPDATE (refresh description/updated_at only, never setting_value) to both rows in data_it_IT.sql and data_en_US.sql so the whole settings seed is fully idempotent: a fresh install still lands the default, a re-seed preserves whatever the admin configured, and no statement errors on a second run. Strengthen tests/seed-idempotency.unit.php to parse every system_settings INSERT and assert each one carries a non-destructive duplicate handler, instead of only checking that some ON DUPLICATE clause exists. --- installer/database/data_en_US.sql | 6 ++++-- installer/database/data_it_IT.sql | 6 ++++-- tests/seed-idempotency.unit.php | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index 701f8ce7..8c70dad1 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -187,10 +187,12 @@ INSERT INTO `generi` VALUES (180,'Music drama',NULL,'2025-10-20 16:20:00','2025- INSERT INTO `generi` VALUES (181,'Oratorio',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',11); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES -('cms', 'events_page_enabled', '1', 'Enable or disable the events page on the frontend'); +('cms', 'events_page_enabled', '1', 'Enable or disable the events page on the frontend') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES -('system', 'catalogue_mode', '0', 'Catalogue-only mode - disables loans, reservations and wishlist'); +('system', 'catalogue_mode', '0', 'Catalogue-only mode - disables loans, reservations and wishlist') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Days to pick up an approved loan before it expires') diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index fdd5573e..b95395dc 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -4,10 +4,12 @@ SET FOREIGN_KEY_CHECKS=0; INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES -('cms', 'events_page_enabled', '1', 'Abilita o disabilita la pagina degli eventi nel frontend'); +('cms', 'events_page_enabled', '1', 'Abilita o disabilita la pagina degli eventi nel frontend') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES -('system', 'catalogue_mode', '0', 'Modalità solo catalogo - disabilita prestiti, prenotazioni e wishlist'); +('system', 'catalogue_mode', '0', 'Modalità solo catalogo - disabilita prestiti, prenotazioni e wishlist') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Giorni per ritirare un prestito approvato prima che scada') diff --git a/tests/seed-idempotency.unit.php b/tests/seed-idempotency.unit.php index 5e9afabb..399878df 100644 --- a/tests/seed-idempotency.unit.php +++ b/tests/seed-idempotency.unit.php @@ -42,6 +42,27 @@ $check(str_contains($sql, 'ON DUPLICATE KEY UPDATE description = VALUES(description)') || str_contains($sql, "ON DUPLICATE KEY UPDATE\n description = VALUES(description)"), "data_{$loc}.sql: system_settings ON DUPLICATE still refreshes description"); + + // EVERY system_settings INSERT must be re-runnable: a plain INSERT + // without ON DUPLICATE would abort the whole re-seed on duplicate key + // *before* reaching the tested clauses (CodeRabbit #266 finding 2). + // Split the file into statements and assert each system_settings + // INSERT carries a non-destructive duplicate handler. + $stmts = preg_split('/;\s*(?:\r?\n|$)/', $sql) ?: []; + $plain = []; + foreach ($stmts as $stmt) { + if (!preg_match('/INSERT\s+INTO\s+`?system_settings`?/i', $stmt)) { + continue; + } + if (!preg_match('/ON\s+DUPLICATE\s+KEY\s+UPDATE/i', $stmt)) { + // Grab the first VALUES key for a readable failure label. + preg_match("/\\(\\s*'[^']*'\\s*,\\s*'([^']*)'/", $stmt, $m); + $plain[] = $m[1] ?? substr(trim($stmt), 0, 40); + } + } + $check($plain === [], + "data_{$loc}.sql: every system_settings INSERT is re-runnable (no ON DUPLICATE on: " + . ($plain === [] ? '—' : implode(', ', $plain)) . ')'); } // ── B. Behavioural: re-seed preserves an admin value, inserts a fresh one ──── From a028e14ca74ffa1f07ea42fc272e66d14f57aa8f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 18 Jul 2026 11:17:49 +0200 Subject: [PATCH 3/3] security: prevent breakout in the custom CSS setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin-only advanced.custom_header_css value is rendered verbatim inside an inline broke out of the style context and executed arbitrary JavaScript for every visitor — a stored XSS gated behind admin write on a field labelled CSS-only. Add ContentSanitizer::sanitizeCustomCss(), which runs the existing font normalisation and then strips every /`; + + await loginAsAdmin(page); + await setCustomCss(page, payload); + + // Capture any dialog the payload might raise. + let dialogFired = false; + page.on('dialog', (d) => { dialogFired = true; d.dismiss().catch(() => {}); }); + + // Load a PUBLIC frontend page (fresh navigation parses the stored CSS). + await page.goto(`${BASE}/`); + await page.waitForLoadState('domcontentloaded'); + + // 1. No JavaScript from the payload ran. + const xssGlobal = await page.evaluate(() => /** @type {any} */ (window).__xss ?? null); + expect(xssGlobal, 'payload JS must not execute').toBeNull(); + expect(dialogFired, 'no dialog may be raised').toBe(false); + expect(await page.title(), 'document.title must be untouched by the payload') + .not.toContain(marker); + + // 2. The parsed DOM must contain no + * broke out of the style context and executed arbitrary JS for every + * visitor — a stored XSS gated behind admin write. + * + * ContentSanitizer::sanitizeCustomCss() now neutralises every + * ', + 'uppercase' => '', + 'mixed-case' => '', + 'space-before-gt' => '', + 'self-closing-style' => '', + 'style-then-img' => 'a{color:red}', + 'style-then-svg' => 'a{}', + 'newline-in-close' => "body{}", + 'tab-in-close' => "body{}", + 'leading-open-style' => '', + 'nested-comment' => '/* */', + 'html-comment-wrap' => '', + 'cdata-wrap' => '</style><script>alert(1)</script>', + 'only-script-open' => 'body{}