From 66cf0e0399874e82a3c115cb92719f9e731ff98f Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 17 Apr 2026 20:17:24 +0000 Subject: [PATCH] Fix live Wealthfolio login + Dockerfile poetry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context ------- Two live-integration bugs surfaced during the Phase 0.5 auth-spike run against the restored production Wealthfolio. 1. Wealthfolio 3.2's LoginRequest schema is `{ password: String }` — it rejects any request with an unknown `username` field as HTTP 400 (empty body, hard to debug). Upstream source: https://github.com/afadil/wealthfolio/blob/main/apps/server/src/auth.rs#L86-L88 2. Dockerfile referenced `/opt/poetry/bin/poetry` but pip install puts poetry on the normal PATH; POETRY_HOME only affects the self-installer, not `pip install`. Exit 127 in GHA build. This change ----------- - WealthfolioSink.login() sends `{password}` only; kept `username` constructor arg as a stub for the day Wealthfolio adds multi-user. - Dockerfile drops POETRY_HOME and uses `poetry` on PATH. - Test: `_login_ok` now asserts body == {"password": "hunter2"} ("hunter2" is the XKCD placeholder — not a real credential). Test plan --------- ## Automated - poetry run pytest -q → 70 passed - poetry run mypy broker_sync tests → Success: no issues found in 29 source files - poetry run ruff check . → All checks passed! ## Manual Verification (executed live) ``` kubectl -n wealthfolio port-forward svc/wealthfolio 18080:80 & WF_BASE_URL=http://localhost:18080 WF_USERNAME=admin \ WF_PASSWORD= \ poetry run broker-sync auth-spike → "Logged in. 1 account(s) visible." ``` --- Dockerfile | 7 ++++--- broker_sync/sinks/wealthfolio.py | 11 ++++++----- tests/sinks/test_wealthfolio.py | 5 +++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 541b67d..a6c526c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,19 @@ FROM python:3.12-slim AS builder ENV POETRY_VERSION=1.8.4 \ - POETRY_HOME=/opt/poetry \ POETRY_VIRTUALENVS_IN_PROJECT=true \ PIP_NO_CACHE_DIR=1 +# `pip install` puts poetry on PATH (/usr/local/bin/poetry) — don't bother +# with POETRY_HOME indirection. RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}" WORKDIR /app COPY pyproject.toml poetry.lock ./ -RUN /opt/poetry/bin/poetry install --only main --no-root +RUN poetry install --only main --no-root COPY broker_sync ./broker_sync -RUN /opt/poetry/bin/poetry install --only main +RUN poetry install --only main FROM python:3.12-slim diff --git a/broker_sync/sinks/wealthfolio.py b/broker_sync/sinks/wealthfolio.py index 42bd0b0..426ec52 100644 --- a/broker_sync/sinks/wealthfolio.py +++ b/broker_sync/sinks/wealthfolio.py @@ -80,17 +80,18 @@ class WealthfolioSink: self._session_path.write_text(json.dumps({"cookies": cookies})) async def login(self) -> None: + # Wealthfolio 3.2's LoginRequest is `{ password: String }` only — a + # username key is rejected as an unknown field (HTTP 400). The + # `username` constructor arg is kept for a future Wealthfolio + # release that may add multi-user support. resp = await self._client.post( _LOGIN_PATH, - json={ - "username": self._username, - "password": self._password - }, + json={"password": self._password}, ) if resp.status_code == 401: raise WealthfolioUnauthorizedError("Wealthfolio /auth/login returned 401") resp.raise_for_status() - cookies = {k: v for k, v in resp.cookies.items()} + cookies = dict(resp.cookies.items()) if not cookies: raise WealthfolioError("/auth/login returned 2xx but no Set-Cookie") self._save_cookies(cookies) diff --git a/tests/sinks/test_wealthfolio.py b/tests/sinks/test_wealthfolio.py index 56f9a52..d8969f8 100644 --- a/tests/sinks/test_wealthfolio.py +++ b/tests/sinks/test_wealthfolio.py @@ -44,10 +44,11 @@ def _client(handler: httpx.MockTransport, session_path: Path) -> WealthfolioSink def _login_ok(req: httpx.Request) -> httpx.Response: assert req.url.path == "/api/v1/auth/login" body = json.loads(req.content) - assert body == {"username": "viktor", "password": "hunter2"} + # Wealthfolio 3.2 LoginRequest is password-only. + assert body == {"password": "hunter2"} return httpx.Response( 200, - json={"ok": True}, + json={"authenticated": True, "expiresIn": 604800}, headers={"set-cookie": "wf_token=abc123; Path=/api; HttpOnly"}, )