diff --git a/backend/app/config.py b/backend/app/config.py index bc603e0..4ed41ef 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -120,6 +120,18 @@ class Settings(BaseSettings): log_level: str = "INFO" + # Per-IP request rate limiting (slowapi). ON by default; the limits + # themselves are declared per-endpoint in the routers. + # + # Turn it OFF only where the "one IP == one client" assumption that + # `get_remote_address` relies on is false. The E2E suite is exactly that + # case: every browser page load in the whole run comes from 127.0.0.1, + # so the limiter ends up measuring the test runner rather than a user — + # a 20-test run makes ~50 `POST /api/auth/refresh` calls (one per page + # load) against a 30/minute budget and starts handing out 429s + # mid-suite. See the matching comment in `frontend/playwright.config.ts`. + rate_limit_enabled: bool = True + # FK scraper — legal/ToS review completed (2026-06); cleared for use. # Default is True. Set APP_SCRAPER_ENABLED=false to disable (e.g. an # environment without Playwright chromium installed). diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py index 27e6851..998ed65 100644 --- a/backend/app/rate_limit.py +++ b/backend/app/rate_limit.py @@ -2,9 +2,16 @@ Lives in its own module so routers can import it without triggering the circular import that would occur if they imported from app.main. + +The limiter keys on the remote address, so it is only meaningful where one +IP really is one client. `settings.rate_limit_enabled` exists to switch it +off where that is not true (the E2E suite drives every page load from +127.0.0.1) — see the comment on that setting in `app/config.py`. """ from slowapi import Limiter from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) +from app.config import settings + +limiter = Limiter(key_func=get_remote_address, enabled=settings.rate_limit_enabled) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 0afc488..baeca25 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -39,6 +39,19 @@ export default defineConfig({ command: 'uv run uvicorn app.main:app --port 8000', cwd: path.resolve(__dirname, '../backend'), url: BACKEND_HEALTH_URL, + // The backend rate-limits per remote IP (slowapi's + // `get_remote_address`), which assumes one IP is one client. This + // suite breaks that assumption: every page load in the whole run + // comes from 127.0.0.1. `POST /api/auth/refresh` fires once per page + // load (AuthProvider re-obtains the access token on mount, see + // AuthContext.tsx) and is capped at 30/minute, so a ~20-test run's + // ~50 refreshes start returning 429 partway through — and a failed + // refresh renders the login screen, which is why unrelated specs + // failed on `getByTestId('dashboard')`/`('calendar')` not being + // found. Raising the cap would only move the cliff and make green-ness + // a function of machine speed; the limiter simply does not apply to a + // single-IP test runner, so it is switched off here. + env: { APP_RATE_LIMIT_ENABLED: 'false' }, reuseExistingServer: !process.env.CI, timeout: 60_000, }, diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index b90432c..ce59068 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -1793,6 +1793,18 @@ describe('Calendar', () => { expect(css).toMatch(/\.calendar-day\s*{[^}]*min-width:\s*0/) expect(css).toMatch(/\.calendar-week__day-cell\s*{[^}]*min-width:\s*0/) expect(css).toMatch(/\.calendar-day__holiday-name\s*{[^}]*min-width:\s*0/) + + // The height half of the same invariant. Each `.calendar-week` is its + // own grid container, so a cell carrying an extra line (the holiday + // name) grew only ITS row — October 2026's last row rendered 3.5px + // taller than the other four. `.calendar__weeks` is therefore a grid + // with `grid-auto-rows: 1fr`, which on an auto-height container sizes + // every row to the tallest one. jsdom does no layout, so this can only + // be asserted against the stylesheet text, exactly like the width + // rules above — `e2e/tests/calendar-grid-uniform.spec.ts` is what + // measures it in a real browser. + expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-auto-rows:\s*1fr/) + expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/) }) it('reserves the same --band-reserved-height on every week row, even though only one week has a band', () => { diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css index a380a31..f75bb5f 100644 --- a/frontend/src/pages/calendar.css +++ b/frontend/src/pages/calendar.css @@ -102,8 +102,27 @@ end, instead of a duplicated chip re-rendered inside every day cell it passes through. */ .calendar__weeks { - display: flex; - flex-direction: column; + /* A grid of one column, not a flex column, purely so `grid-auto-rows: 1fr` + can equalize the week rows. Each `.calendar-week` is its own separate + grid container (see below), so nothing inside one row can see how tall + another row is — which is how the month containing "Alla helgons dag" + ended up with its last row 3.5px taller than the other four: that one + cell carries an extra `.calendar-day__holiday-name` line, and only its + own row grew to fit it. + + On an auto-height grid container, every `1fr` row resolves to the same + size — the largest of their content contributions — so the tallest week + sets the height of all of them. That keeps rows uniform by construction + rather than by a magic `min-height` that has to be re-tuned whenever a + cell gains a badge, a line, or a longer translation (labels are + Swedish/English, so text heights are not fixed). + + `minmax(0, 1fr)` for the single column for the same reason + `.calendar-week` uses it — a bare auto/`1fr` column takes its minimum + from content, which would re-open the width half of the same bug. */ + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-rows: 1fr; gap: var(--sp-2); margin-bottom: var(--sp-5); }