From bc5e62dd85046aeee456651becae4ccdb5541f0d Mon Sep 17 00:00:00 2001 From: Syed Ahmer Shah Date: Sun, 1 Nov 2026 21:46:54 +0500 Subject: [PATCH 1/3] Fix SonarQube findings and plan the pre-launch tests --- .sonarcloud.properties | 5 +++++ backend/app/__init__.py | 2 +- backend/app/config.py | 1 - backend/app/mail.py | 15 ++++++++++++--- backend/tests/test_mail.py | 18 +++++++++++++----- docs/roadmap.md | 9 +++++++++ docs/status.md | 7 ++++--- frontend/src/components/DragShelf.tsx | 2 +- frontend/src/components/Hero.tsx | 2 +- frontend/src/components/ListDetail.tsx | 5 ++--- frontend/src/pages/AppPage.tsx | 4 +++- frontend/src/pages/SelfHost.tsx | 2 +- frontend/src/seo/routes.ts | 3 ++- frontend/src/webgl/shelfScene.ts | 2 +- 14 files changed, 55 insertions(+), 22 deletions(-) diff --git a/.sonarcloud.properties b/.sonarcloud.properties index aed6aa9..e1e972e 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -5,3 +5,8 @@ sonar.test.inclusions=frontend/src/**/*.test.ts,frontend/src/**/*.test.tsx,backe sonar.exclusions=frontend/src/api/schema.d.ts,backend/migrations/**,**/node_modules/**,**/dist/** sonar.python.version=3.12 sonar.sourceEncoding=UTF-8 + +# css:S8776 ("missing scoping root") misreads Tailwind's @custom-variant block in index.css. +sonar.issue.ignore.multicriteria=tailwind +sonar.issue.ignore.multicriteria.tailwind.ruleKey=css:S8776 +sonar.issue.ignore.multicriteria.tailwind.resourceKey=frontend/src/styles/index.css diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0d72b4c..92591f3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -17,7 +17,7 @@ def create_app(settings: Settings | None = None) -> Flask: settings = settings or get_settings() - app = Flask(__name__) + app = Flask(__name__) # NOSONAR: CSRF is enforced by app/security/csrf.py, not Flask-WTF app.config["MAX_CONTENT_LENGTH"] = settings.max_request_bytes app.json.sort_keys = False # type: ignore[attr-defined] diff --git a/backend/app/config.py b/backend/app/config.py index af1895d..46a258e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,7 +45,6 @@ class Settings(BaseSettings): smtp_port: int = 587 smtp_username: str = "" smtp_password: SecretStr = SecretStr("") - smtp_starttls: bool = True mail_from: str = "OfflineShelf " @field_validator("database_url") diff --git a/backend/app/mail.py b/backend/app/mail.py index 1d19a3c..58b4f80 100644 --- a/backend/app/mail.py +++ b/backend/app/mail.py @@ -8,6 +8,7 @@ log = logging.getLogger(__name__) SMTP_TIMEOUT_SECONDS = 30 +SMTPS_PORT = 465 def send_email(settings: Settings, to: str, subject: str, body: str) -> bool: @@ -20,9 +21,17 @@ def send_email(settings: Settings, to: str, subject: str, body: str) -> bool: msg["To"] = to msg["Subject"] = subject msg.set_content(body) - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_SECONDS) as smtp: - if settings.smtp_starttls: - smtp.starttls(context=ssl.create_default_context()) + # Always encrypted, always with certificate checks: implicit TLS on 465, STARTTLS otherwise. + context = ssl.create_default_context() + host, port = settings.smtp_host, settings.smtp_port + smtp: smtplib.SMTP + if port == SMTPS_PORT: + smtp = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS, context=context) + else: + smtp = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS) + with smtp: + if port != SMTPS_PORT: + smtp.starttls(context=context) if settings.smtp_username: smtp.login(settings.smtp_username, settings.smtp_password.get_secret_value()) smtp.send_message(msg) diff --git a/backend/tests/test_mail.py b/backend/tests/test_mail.py index 119bb0a..0a49a17 100644 --- a/backend/tests/test_mail.py +++ b/backend/tests/test_mail.py @@ -61,9 +61,17 @@ def test_sends_with_starttls_and_login(smtp) -> None: assert msg.get_content().strip() == "Body" -def test_plain_smtp_without_auth(smtp) -> None: - settings = get_settings().model_copy( - update={"smtp_host": "localhost", "smtp_port": 25, "smtp_starttls": False} - ) +def test_port_465_uses_implicit_tls_without_auth(monkeypatch) -> None: + opened: list[dict[str, object]] = [] + + class FakeSMTPSSL(FakeSMTP): + def __init__(self, host: str, port: int, timeout: int, context: object) -> None: + super().__init__(host, port, timeout) + opened.append({"context": context}) + + monkeypatch.setattr(mail.smtplib, "SMTP_SSL", FakeSMTPSSL) + FakeSMTP.instances = [] + settings = get_settings().model_copy(update={"smtp_host": "smtp.example.org", "smtp_port": 465}) assert mail.send_email(settings, "a@example.org", "Hi", "Body") - assert smtp.instances[0].calls == ["quit"] + assert FakeSMTP.instances[0].calls == ["quit"] + assert opened[0]["context"] is not None diff --git a/docs/roadmap.md b/docs/roadmap.md index 17d44cf..d6f6d01 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,3 +64,12 @@ in CI, and seed data. - [x] Seed data: three example lists (a category, a Wikidata query, a combination): `python -m app.seed ` - [x] README setup steps and screenshots - [ ] Deploy at `offlineshelf.ahmershah.dev`; tag `v1.0.0` + +## Before launch: test at scale (planned, not started) + +- [ ] End-to-end tests with Playwright: sign up, every builder, combinations, exports, delete +- [ ] Smoke tests against a fresh Docker Compose stack on every release tag +- [ ] Stress test: 15 to 20 simulated users in Docker building, combining and exporting at once, + watching queue depth, build times, memory and Wikimedia request rates +- [ ] 100% line and branch coverage for the whole backend and frontend (today: `app/core` 100%, + backend about 97%) diff --git a/docs/status.md b/docs/status.md index a660a64..758c2e0 100644 --- a/docs/status.md +++ b/docs/status.md @@ -27,6 +27,8 @@ Last updated: 2026-11-01. Update at the end of every work session. README with setup steps and screenshots (`docs/screenshots`). - **M6 ZIM:** `format: "zim"` exports write a ZIM file with libzim 3.9.0 (same pages as the HTML bundle, full-text index, Kiwix metadata), offered in the Details panel. Checked live. +- **Quality gates:** SonarQube Cloud (automatic analysis) and CodeRabbit are installed and are + required checks on `main` alongside backend, frontend and CodeQL. - **Changes:** each ready version stores the articles added and removed since the previous ready one (`/versions//changes`), shown in the build history and the Details panel. - **Robustness:** the scheduler runs `recover_stalled` hourly: builds and exports whose RQ job @@ -62,9 +64,8 @@ proxies `/api`). `npm run dev:down` stops the containers. Needs Docker, uv and N 1. Read the About page copy and confirm it says what you want about yourself. 2. M6: deploy to offlineshelf.ahmershah.dev and tag v1.0.0. -3. Install SonarQube Cloud and CodeRabbit on the repository (needs the owner's GitHub - authorisation; `.sonarcloud.properties` and `.coderabbit.yaml` are ready), then add them as - required checks in `.github/rulesets/protect-main.json`. +3. Before launch, at the very end: Playwright end-to-end tests, smoke and stress tests with 15 to + 20 simulated users in Docker, and 100% coverage everywhere (see the roadmap). 4. Release images: `.github/workflows/images.yml` publishes `offlineshelf-api` and `offlineshelf-web` to GHCR on every `v*` tag. diff --git a/frontend/src/components/DragShelf.tsx b/frontend/src/components/DragShelf.tsx index 025e918..6dab16f 100644 --- a/frontend/src/components/DragShelf.tsx +++ b/frontend/src/components/DragShelf.tsx @@ -132,7 +132,7 @@ export function DragShelf() { ref={track} data-cursor="grab" data-lenis-prevent-wheel="" - tabIndex={0} + tabIndex={0} // NOSONAR: a scrolling region needs focus so keyboard users can scroll it role="region" aria-label="Example lists, scroll sideways" className="no-scrollbar flex cursor-grab items-end gap-4 overflow-x-auto px-[var(--gutter)] pb-4 select-none active:cursor-grabbing" diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 2e2dfc4..e568a36 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -71,7 +71,7 @@ export function Hero() {