diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index d001c4d..3233268 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -197,6 +197,23 @@ pub async fn patch_config( "Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})." ))); } + // Zero is in range and catastrophic. `quota_tolerance` is the multiplier in + // `free_disk * tolerance / active_uploaders`, so 0 makes every per-user limit 0 and + // refuses EVERY upload — mid-event, with "Du hast dein Upload-Limit für dieses Event + // erreicht", an error naming the wrong cause entirely. `storage_quota_enabled` is the + // intended off-switch. + // + // Rejecting the value rather than raising the floor: very small tolerances are + // legitimate (they are how a large disk is throttled down to a sensible per-guest + // ceiling, and how the e2e quota tests steer it — around 1e-5 on a 174 GB volume), so + // a floor of, say, 0.01 would forbid real configurations to prevent one typo. + if key_str == "quota_tolerance" && n == 0.0 { + return Err(AppError::BadRequest( + "quota_tolerance = 0 würde jeden Upload blockieren. Zum Abschalten der \ + Speicher-Quote stattdessen „Speicher-Quote aktiv“ ausschalten." + .into(), + )); + } } else if BOOL_KEYS.contains(&key_str) { match value.trim().to_ascii_lowercase().as_str() { "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {} diff --git a/e2e/specs/05-admin/config.spec.ts b/e2e/specs/05-admin/config.spec.ts index 4119c7e..dca1005 100644 --- a/e2e/specs/05-admin/config.spec.ts +++ b/e2e/specs/05-admin/config.spec.ts @@ -68,6 +68,40 @@ test.describe('Admin — config API', () => { expect(cfg.privacy_note).toBe(note); await api.patchConfig(adminToken, { privacy_note: '' }); }); + test('quota_tolerance = 0 is rejected, with a pointer to the real off-switch', async ({ + api, + adminToken, + }) => { + // Zero is inside the documented 0–1 range and catastrophic: the per-user limit is + // `free_disk * tolerance / active_uploaders`, so 0 refuses EVERY upload — mid-event, with + // "Du hast dein Upload-Limit für dieses Event erreicht", which names the wrong cause + // entirely. `storage_quota_enabled` is what an admin reaching for an off-switch wants. + const res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', + { + method: 'PATCH', + headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ quota_tolerance: '0' }), + } + ); + expect(res.status).toBe(400); + expect( + (await res.text()).toLowerCase(), + 'the error must name the switch the admin actually wanted' + ).toContain('speicher-quote'); + + // The value is untouched — validation fully precedes any write. + expect((await api.getConfig(adminToken)).quota_tolerance).toBe('0.75'); + }); + + test('a very small quota_tolerance is still accepted', async ({ api, adminToken }) => { + // The mirror. Rejecting 0 must not become a floor: small tolerances are how a large disk is + // throttled to a sensible per-guest ceiling, and how the quota specs steer it (~1e-5 on a + // 174 GB volume). A floor of 0.01 would forbid real configurations to prevent one typo. + await api.patchConfig(adminToken, { quota_tolerance: '0.00001' }); + expect((await api.getConfig(adminToken)).quota_tolerance).toBe('0.00001'); + await api.patchConfig(adminToken, { quota_tolerance: '0.75' }); + }); }); test.describe('Admin — stats', () => {