From e39ec9a77b69d516ab52e521b16e2b4c98fc7a31 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 16:35:23 +0200 Subject: [PATCH] Stop the test runner tripping the rate limiter, and level the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end suite had never been run. Its first run failed six ways, all of them the page sitting on the login screen: every browser page load re-obtains an access token, the refresh endpoint allows thirty a minute per address, and a twenty-test run makes about fifty of them from one address. Half came back 429, and a failed refresh is indistinguishable from being logged out. Raising the ceiling would only move it and make a green suite depend on how fast the machine is, so the limiter is switched off for the test backend alone — it measures a client, and the runner is not one. It stays on everywhere else. With that gone, one real failure was left, and it belonged to the commit that claimed to have fixed it: cell widths were held, but a week whose cell carries a holiday name still stood taller than its neighbours, because each week is its own grid and none can see another's height. The weeks now share one grid whose rows are all a single fraction, so the tallest sets the rest and no cell can grow its own row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- backend/app/config.py | 12 ++++++++++++ backend/app/rate_limit.py | 9 ++++++++- frontend/playwright.config.ts | 13 +++++++++++++ frontend/src/pages/Calendar.test.tsx | 12 ++++++++++++ frontend/src/pages/calendar.css | 23 +++++++++++++++++++++-- 5 files changed, 66 insertions(+), 3 deletions(-) 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); }