mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Catering: answer the panel's CSRF 403 at login; kuchnia --log publishes
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b82faf8f9
commit
e6432d5b9d
@ -79,7 +79,7 @@ every idle tick to approximate the two moments state actually changes here.
|
|||||||
- Every test that logs a meal would otherwise hit the network through these new
|
- Every test that logs a meal would otherwise hit the network through these new
|
||||||
call sites; `conftest._isolate_state` patches each one
|
call sites; `conftest._isolate_state` patches each one
|
||||||
(listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`,
|
(listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`,
|
||||||
`_cli_body` publish sites and `_cli_gate.pull_peer_logs`) — not on
|
`_cli_body`, `_cli_kuchnia` publish sites and `_cli_gate.pull_peer_logs`) — not on
|
||||||
`_sync_events`, so a direct test still exercises the real helper. A new
|
`_sync_events`, so a direct test still exercises the real helper. A new
|
||||||
network entry point missing from that list reaches the real remote.
|
network entry point missing from that list reaches the real remote.
|
||||||
|
|
||||||
|
|||||||
@ -32,12 +32,16 @@ const kuchniaCompany = 'kuchniavikinga';
|
|||||||
/// The panel's own launcher tag for a browser panel session.
|
/// The panel's own launcher tag for a browser panel session.
|
||||||
const kuchniaLauncherType = 'BROWSER_PANEL';
|
const kuchniaLauncherType = 'BROWSER_PANEL';
|
||||||
|
|
||||||
/// The only cookie the panel issues on login.
|
/// The cookie that authenticates every call after login.
|
||||||
///
|
|
||||||
/// It sets no `XSRF-TOKEN`, so the CSRF echo its own JavaScript performs is a
|
|
||||||
/// no-op for this account -- the session cookie alone authenticates.
|
|
||||||
const kuchniaSessionCookie = 'SESSION';
|
const kuchniaSessionCookie = 'SESSION';
|
||||||
|
|
||||||
|
/// Spring's CSRF cookie. Since ~2026-10 the panel refuses a login without it
|
||||||
|
/// echoed back in [kuchniaXsrfHeader], answering with a bare 403 that sets it.
|
||||||
|
const kuchniaXsrfCookie = 'XSRF-TOKEN';
|
||||||
|
|
||||||
|
/// The header that echoes [kuchniaXsrfCookie].
|
||||||
|
const kuchniaXsrfHeader = 'X-XSRF-TOKEN';
|
||||||
|
|
||||||
/// Per-request timeout.
|
/// Per-request timeout.
|
||||||
const kuchniaTimeout = Duration(seconds: 8);
|
const kuchniaTimeout = Duration(seconds: 8);
|
||||||
|
|
||||||
@ -58,14 +62,18 @@ const kuchniaWebUnsupportedReason =
|
|||||||
/// a panel that sets more than one cookie hands over
|
/// a panel that sets more than one cookie hands over
|
||||||
/// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is
|
/// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is
|
||||||
/// absent, which the caller treats as a failed login rather than guessing.
|
/// absent, which the caller treats as a failed login rather than guessing.
|
||||||
String? sessionCookieFrom(String? setCookieHeader) {
|
String? sessionCookieFrom(String? setCookieHeader) =>
|
||||||
|
cookieFrom(setCookieHeader, kuchniaSessionCookie);
|
||||||
|
|
||||||
|
/// Extracts the cookie called [name] the same way as [sessionCookieFrom].
|
||||||
|
String? cookieFrom(String? setCookieHeader, String name) {
|
||||||
if (setCookieHeader == null || setCookieHeader.isEmpty) return null;
|
if (setCookieHeader == null || setCookieHeader.isEmpty) return null;
|
||||||
// Split on both separators: ',' joins whole cookies, ';' separates a
|
// Split on both separators: ',' joins whole cookies, ';' separates a
|
||||||
// cookie's attributes from its value.
|
// cookie's attributes from its value.
|
||||||
for (final part in setCookieHeader.split(RegExp('[,;]'))) {
|
for (final part in setCookieHeader.split(RegExp('[,;]'))) {
|
||||||
final trimmed = part.trim();
|
final trimmed = part.trim();
|
||||||
if (trimmed.startsWith('$kuchniaSessionCookie=')) {
|
if (trimmed.startsWith('$name=')) {
|
||||||
final value = trimmed.substring(kuchniaSessionCookie.length + 1);
|
final value = trimmed.substring(name.length + 1);
|
||||||
if (value.isNotEmpty) return value;
|
if (value.isNotEmpty) return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,8 +89,7 @@ class KuchniaSession {
|
|||||||
http.Client? client,
|
http.Client? client,
|
||||||
DateTime? startedAt,
|
DateTime? startedAt,
|
||||||
}) : _client = client ?? http.Client(),
|
}) : _client = client ?? http.Client(),
|
||||||
_deadline =
|
_deadline = (startedAt ?? DateTime.now()).add(kuchniaTotalDeadline);
|
||||||
(startedAt ?? DateTime.now()).add(kuchniaTotalDeadline);
|
|
||||||
|
|
||||||
/// The panel e-mail.
|
/// The panel e-mail.
|
||||||
final String username;
|
final String username;
|
||||||
@ -120,25 +127,23 @@ class KuchniaSession {
|
|||||||
/// sticky JSON default would mislabel it. Passing a `Map` to `http.post`
|
/// sticky JSON default would mislabel it. Passing a `Map` to `http.post`
|
||||||
/// sets the right one automatically.
|
/// sets the right one automatically.
|
||||||
Future<void> login() async {
|
Future<void> login() async {
|
||||||
_checkDeadline();
|
var response = await _postLogin(null);
|
||||||
final http.Response response;
|
final xsrf = cookieFrom(response.headers['set-cookie'], kuchniaXsrfCookie);
|
||||||
try {
|
if (response.statusCode == 403 && xsrf != null) {
|
||||||
response = await _client
|
// The CSRF handshake: the 403 handed out the token, echo it once.
|
||||||
.post(
|
response = await _postLogin(xsrf);
|
||||||
Uri.parse('$kuchniaApiBase/auth/login'),
|
|
||||||
headers: _headers,
|
|
||||||
body: {'username': username, 'password': password},
|
|
||||||
)
|
|
||||||
.timeout(kuchniaTimeout);
|
|
||||||
} on Exception catch (error) {
|
|
||||||
throw KuchniaError('catering panel unreachable: $error');
|
|
||||||
}
|
}
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
// A 401 is a wrong password, and on the phone the only place to fix it
|
// A 401 is a wrong password, and on the phone the only place to fix it
|
||||||
// is Settings; any other status is the panel's problem, not the user's.
|
// is Settings. A 403 that survives the handshake is the panel's
|
||||||
final hint = response.statusCode == 401
|
// protection; any other status is the panel's problem, not the user's.
|
||||||
? ' — check the password in Settings'
|
final hint = switch (response.statusCode) {
|
||||||
: '';
|
401 => ' — check the password in Settings',
|
||||||
|
403 =>
|
||||||
|
" — the panel's CSRF/bot protection refused it, "
|
||||||
|
'not a password problem',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
throw KuchniaError(
|
throw KuchniaError(
|
||||||
'catering login rejected (HTTP ${response.statusCode})$hint',
|
'catering login rejected (HTTP ${response.statusCode})$hint',
|
||||||
);
|
);
|
||||||
@ -150,6 +155,28 @@ class KuchniaSession {
|
|||||||
sessionCookie = cookie;
|
sessionCookie = cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<http.Response> _postLogin(String? xsrf) async {
|
||||||
|
_checkDeadline();
|
||||||
|
final headers = {
|
||||||
|
..._headers,
|
||||||
|
if (xsrf != null) ...{
|
||||||
|
kuchniaXsrfHeader: xsrf,
|
||||||
|
'Cookie': '$kuchniaXsrfCookie=$xsrf',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('$kuchniaApiBase/auth/login'),
|
||||||
|
headers: headers,
|
||||||
|
body: {'username': username, 'password': password},
|
||||||
|
)
|
||||||
|
.timeout(kuchniaTimeout);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
throw KuchniaError('catering panel unreachable: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GETs [path] and decodes it, re-logging in once if the cookie expired.
|
/// GETs [path] and decodes it, re-logging in once if the cookie expired.
|
||||||
///
|
///
|
||||||
/// A cached cookie that has expired is indistinguishable from a good one
|
/// A cached cookie that has expired is indistinguishable from a good one
|
||||||
|
|||||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
|||||||
description: "Companion phone app for diet_guard: log meals on the go."
|
description: "Companion phone app for diet_guard: log meals on the go."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.114+1
|
version: 1.0.115+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -42,4 +42,75 @@ void main() {
|
|||||||
expect(error.message, isNot(contains(_settingsHint)));
|
expect(error.message, isNot(contains(_settingsHint)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('the CSRF handshake', () {
|
||||||
|
// Since ~2026-10 the panel answers a login without `X-XSRF-TOKEN` with a
|
||||||
|
// bare 403 that sets the XSRF-TOKEN cookie; a retry echoing it gets the
|
||||||
|
// real answer. Without the retry every login failed with HTTP 403.
|
||||||
|
const token = 'csrf-123';
|
||||||
|
|
||||||
|
/// A panel that 403s until the token is echoed, then answers [then].
|
||||||
|
KuchniaSession panel(
|
||||||
|
http.Response Function() then,
|
||||||
|
List<http.Request> seen,
|
||||||
|
) {
|
||||||
|
return KuchniaSession(
|
||||||
|
username: 'me@example.com',
|
||||||
|
password: 'pw',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
seen.add(request);
|
||||||
|
final echoed =
|
||||||
|
request.headers['X-XSRF-TOKEN'] == token &&
|
||||||
|
(request.headers['Cookie'] ?? '').contains('XSRF-TOKEN=$token');
|
||||||
|
if (!echoed) {
|
||||||
|
return http.Response(
|
||||||
|
'',
|
||||||
|
403,
|
||||||
|
headers: {'set-cookie': 'XSRF-TOKEN=$token; Path=/; Secure'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return then();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a 403 is retried once echoing the token', () async {
|
||||||
|
final seen = <http.Request>[];
|
||||||
|
final session = panel(
|
||||||
|
() => http.Response(
|
||||||
|
'',
|
||||||
|
200,
|
||||||
|
headers: {'set-cookie': 'SESSION=sess-1; Path=/; HttpOnly'},
|
||||||
|
),
|
||||||
|
seen,
|
||||||
|
);
|
||||||
|
await session.login();
|
||||||
|
expect(seen, hasLength(2));
|
||||||
|
expect(session.sessionCookie, 'sess-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong password behind CSRF still points at Settings', () async {
|
||||||
|
final session = panel(() => http.Response('Bad credentials', 401), []);
|
||||||
|
await expectLater(
|
||||||
|
session.login(),
|
||||||
|
throwsA(
|
||||||
|
isA<KuchniaError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains(_settingsHint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a 403 that survives the handshake is not a password problem',
|
||||||
|
() async {
|
||||||
|
final error = await _loginError(403);
|
||||||
|
expect(error.message, contains('HTTP 403'));
|
||||||
|
expect(error.message, contains('not a password problem'));
|
||||||
|
expect(error.message, isNot(contains(_settingsHint)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from diet_guard._kuchnia_spread import assign_slots
|
|||||||
from diet_guard._meal_schedule_store import current_schedule
|
from diet_guard._meal_schedule_store import current_schedule
|
||||||
from diet_guard._slots import day_slots, slot_label
|
from diet_guard._slots import day_slots, slot_label
|
||||||
from diet_guard._state import now_local
|
from diet_guard._state import now_local
|
||||||
|
from diet_guard._sync_events import publish_after_log
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
import argparse
|
import argparse
|
||||||
@ -134,5 +135,11 @@ def _log_into_empty_slots(
|
|||||||
if not yes and ask(question).strip().lower() != "y":
|
if not yes and ask(question).strip().lower() != "y":
|
||||||
emit("nothing logged.")
|
emit("nothing logged.")
|
||||||
return
|
return
|
||||||
written = sys.modules[__name__].log_dishes(plan)
|
module = sys.modules[__name__]
|
||||||
|
written = module.log_dishes(plan)
|
||||||
emit(f"logged {len(written)} meal(s).")
|
emit(f"logged {len(written)} meal(s).")
|
||||||
|
# Push now, like every other write path: until a peer sees these, the
|
||||||
|
# phone keeps offering the same dishes and invites a double log.
|
||||||
|
failure = publish_after_log()
|
||||||
|
if failure is not None:
|
||||||
|
emit(f"saved locally, not yet published ({failure}).")
|
||||||
|
|||||||
@ -45,6 +45,11 @@ _LAZY_ATTRS = ("requests",)
|
|||||||
|
|
||||||
_COOKIE_DOMAIN = "panel.kuchniavikinga.pl"
|
_COOKIE_DOMAIN = "panel.kuchniavikinga.pl"
|
||||||
|
|
||||||
|
# Spring's CSRF pair: the panel sets the cookie on a refused login and wants it
|
||||||
|
# echoed back in the header.
|
||||||
|
_XSRF_COOKIE = "XSRF-TOKEN"
|
||||||
|
_XSRF_HEADER = "X-XSRF-TOKEN"
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> object:
|
def __getattr__(name: str) -> object:
|
||||||
"""Resolve the deferred ``requests`` import on first attribute access."""
|
"""Resolve the deferred ``requests`` import on first attribute access."""
|
||||||
@ -111,14 +116,25 @@ class PanelSession:
|
|||||||
msg = f"catering panel unreachable: {exc}"
|
msg = f"catering panel unreachable: {exc}"
|
||||||
raise KuchniaError(msg) from exc
|
raise KuchniaError(msg) from exc
|
||||||
|
|
||||||
|
def _post_login(self) -> requests.Response:
|
||||||
|
"""POST the stored credentials, answering the CSRF handshake once.
|
||||||
|
|
||||||
|
The panel enforces CSRF since ~2026-10: a login without the header gets
|
||||||
|
a bare 403 that also sets the ``XSRF-TOKEN`` cookie. ``requests``
|
||||||
|
stores that cookie, so one retry echoing it gets the real answer.
|
||||||
|
"""
|
||||||
|
username, password = read_credentials()
|
||||||
|
body = {"username": username, "password": password}
|
||||||
|
response = self._request("POST", "auth/login", data=body)
|
||||||
|
token = self._session.cookies.get(_XSRF_COOKIE)
|
||||||
|
if response.status_code == HTTPStatus.FORBIDDEN and token:
|
||||||
|
self._session.headers[_XSRF_HEADER] = token
|
||||||
|
response = self._request("POST", "auth/login", data=body)
|
||||||
|
return response
|
||||||
|
|
||||||
def _login(self) -> None:
|
def _login(self) -> None:
|
||||||
"""Authenticate with the stored credentials and cache the cookie."""
|
"""Authenticate with the stored credentials and cache the cookie."""
|
||||||
username, password = read_credentials()
|
response = self._post_login()
|
||||||
response = self._request(
|
|
||||||
"POST",
|
|
||||||
"auth/login",
|
|
||||||
data={"username": username, "password": password},
|
|
||||||
)
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
msg = f"catering login rejected (HTTP {response.status_code})"
|
msg = f"catering login rejected (HTTP {response.status_code})"
|
||||||
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||||
@ -128,6 +144,13 @@ class PanelSession:
|
|||||||
" -- check the password in "
|
" -- check the password in "
|
||||||
f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}"
|
f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}"
|
||||||
)
|
)
|
||||||
|
elif response.status_code == HTTPStatus.FORBIDDEN:
|
||||||
|
# A wrong password is a 401 even behind CSRF; a 403 that
|
||||||
|
# survives the handshake is the panel's protection refusing.
|
||||||
|
msg += (
|
||||||
|
" -- the panel's CSRF/bot protection refused it, "
|
||||||
|
"not a password problem"
|
||||||
|
)
|
||||||
raise KuchniaError(msg)
|
raise KuchniaError(msg)
|
||||||
cookie = self._session.cookies.get(SESSION_COOKIE)
|
cookie = self._session.cookies.get(SESSION_COOKIE)
|
||||||
if cookie is None:
|
if cookie is None:
|
||||||
|
|||||||
@ -25,10 +25,21 @@ class FakeResponse:
|
|||||||
"""The slice of ``requests.Response`` the client actually touches."""
|
"""The slice of ``requests.Response`` the client actually touches."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, status: int = 200, payload: object = None, *, text: str = ""
|
self,
|
||||||
|
status: int = 200,
|
||||||
|
payload: object = None,
|
||||||
|
*,
|
||||||
|
text: str = "",
|
||||||
|
cookies: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build a response with ``status`` and either JSON or raw text."""
|
"""Build a response with ``status`` and either JSON or raw text.
|
||||||
|
|
||||||
|
``cookies`` land in the session's jar when the response is replayed,
|
||||||
|
the way ``requests`` absorbs a ``Set-Cookie`` -- the panel's CSRF 403
|
||||||
|
hands out its ``XSRF-TOKEN`` that way.
|
||||||
|
"""
|
||||||
self.status_code = status
|
self.status_code = status
|
||||||
|
self.cookies = cookies or {}
|
||||||
self._payload = payload
|
self._payload = payload
|
||||||
self.text = text or (json.dumps(payload) if payload is not None else "")
|
self.text = text or (json.dumps(payload) if payload is not None else "")
|
||||||
|
|
||||||
@ -62,6 +73,8 @@ class FakeSession:
|
|||||||
self.cookies = FakeCookieJar()
|
self.cookies = FakeCookieJar()
|
||||||
self.calls: list[tuple[str, str]] = []
|
self.calls: list[tuple[str, str]] = []
|
||||||
self.bodies: list[dict[str, str]] = []
|
self.bodies: list[dict[str, str]] = []
|
||||||
|
# A snapshot per request: the session-level dict mutates afterwards.
|
||||||
|
self.sent_headers: list[dict[str, str]] = []
|
||||||
self._responses: Iterator[FakeResponse] = iter(responses or [])
|
self._responses: Iterator[FakeResponse] = iter(responses or [])
|
||||||
self.login_cookie: str | None = "session-abc"
|
self.login_cookie: str | None = "session-abc"
|
||||||
self.raise_on_request: Exception | None = None
|
self.raise_on_request: Exception | None = None
|
||||||
@ -78,14 +91,17 @@ class FakeSession:
|
|||||||
if self.raise_on_request is not None:
|
if self.raise_on_request is not None:
|
||||||
raise self.raise_on_request
|
raise self.raise_on_request
|
||||||
self.calls.append((method, url))
|
self.calls.append((method, url))
|
||||||
|
self.sent_headers.append(dict(self.headers))
|
||||||
if data is not None:
|
if data is not None:
|
||||||
self.bodies.append(data)
|
self.bodies.append(data)
|
||||||
if method == "POST" and url.endswith("auth/login") and self.login_cookie:
|
if method == "POST" and url.endswith("auth/login") and self.login_cookie:
|
||||||
self.cookies.set("SESSION", self.login_cookie)
|
self.cookies.set("SESSION", self.login_cookie)
|
||||||
try:
|
try:
|
||||||
return next(self._responses)
|
response = next(self._responses)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
return FakeResponse(200, {})
|
return FakeResponse(200, {})
|
||||||
|
self.cookies.update(response.cookies)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def fake_requests(session: FakeSession) -> SimpleNamespace:
|
def fake_requests(session: FakeSession) -> SimpleNamespace:
|
||||||
|
|||||||
@ -98,6 +98,7 @@ def state_redirects(tmp_path: Path) -> list[_patch[object]]:
|
|||||||
patch("diet_guard._cli_gate.publish_after_log", return_value=None),
|
patch("diet_guard._cli_gate.publish_after_log", return_value=None),
|
||||||
patch("diet_guard._mcp.publish_after_log", return_value=None),
|
patch("diet_guard._mcp.publish_after_log", return_value=None),
|
||||||
patch("diet_guard._cli_body.publish_after_log", return_value=None),
|
patch("diet_guard._cli_body.publish_after_log", return_value=None),
|
||||||
|
patch("diet_guard._cli_kuchnia.publish_after_log", return_value=None),
|
||||||
# The gate's pre-lock refresh is a *second*, independent network
|
# The gate's pre-lock refresh is a *second*, independent network
|
||||||
# entry point (narrow peer-log pull, not the full tick). Without
|
# entry point (narrow peer-log pull, not the full tick). Without
|
||||||
# this every test that reaches `_should_lock` hits the real remote.
|
# this every test that reaches `_should_lock` hits the real remote.
|
||||||
|
|||||||
@ -160,3 +160,50 @@ def test_unknown_lazy_attribute_raises() -> None:
|
|||||||
missing = "not_a_real_helper"
|
missing = "not_a_real_helper"
|
||||||
with pytest.raises(AttributeError, match="no attribute"):
|
with pytest.raises(AttributeError, match="no attribute"):
|
||||||
getattr(_cli_kuchnia, missing)
|
getattr(_cli_kuchnia, missing)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublish:
|
||||||
|
"""``--log`` pushes the new entries, like every other write path.
|
||||||
|
|
||||||
|
Without it the phone kept offering "5 more to go" for dishes the PC had
|
||||||
|
already logged (2026-10-17), inviting a double log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _log(self, lines: list[str], written: list[str]) -> object:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_cli_kuchnia, "refresh_delivery", return_value=([_dish()], None)
|
||||||
|
),
|
||||||
|
patch.object(_cli_kuchnia, "log_dishes", return_value=written),
|
||||||
|
patch.object(_cli_kuchnia, "publish_after_log", return_value=None) as pub,
|
||||||
|
):
|
||||||
|
_cli_kuchnia.cmd_kuchnia(lines.append, lambda _p: "y", log=True, yes=True)
|
||||||
|
return pub
|
||||||
|
|
||||||
|
def test_logging_publishes_once(self) -> None:
|
||||||
|
assert self._log([], ["A", "B"]).call_count == 1
|
||||||
|
|
||||||
|
def test_a_dry_run_does_not_publish(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_cli_kuchnia, "refresh_delivery", return_value=([_dish()], None)
|
||||||
|
),
|
||||||
|
patch.object(_cli_kuchnia, "publish_after_log") as pub,
|
||||||
|
):
|
||||||
|
_cli_kuchnia.cmd_kuchnia([].append, lambda _p: "y", log=False, yes=False)
|
||||||
|
assert pub.call_count == 0
|
||||||
|
|
||||||
|
def test_an_outage_is_reported_not_raised(self) -> None:
|
||||||
|
lines: list[str] = []
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
_cli_kuchnia, "refresh_delivery", return_value=([_dish()], None)
|
||||||
|
),
|
||||||
|
patch.object(_cli_kuchnia, "log_dishes", return_value=["A"]),
|
||||||
|
patch.object(_cli_kuchnia, "publish_after_log", return_value="offline"),
|
||||||
|
):
|
||||||
|
code = _cli_kuchnia.cmd_kuchnia(
|
||||||
|
lines.append, lambda _p: "y", log=True, yes=True
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
assert any("not yet published (offline)" in line for line in lines)
|
||||||
|
|||||||
70
diet_guard/tests/test_kuchnia_csrf.py
Normal file
70
diet_guard/tests/test_kuchnia_csrf.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""The panel's CSRF handshake at login.
|
||||||
|
|
||||||
|
Since ~2026-10 the panel enforces Spring's CSRF check: a login without an
|
||||||
|
``X-XSRF-TOKEN`` header gets a bare 403 that also sets the ``XSRF-TOKEN``
|
||||||
|
cookie. The client retries once echoing it. Before this, every login failed
|
||||||
|
with "login rejected (HTTP 403)" whatever the password was.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from diet_guard import _kuchnia_client, _kuchnia_config
|
||||||
|
from diet_guard._kuchnia_client import PanelSession
|
||||||
|
from diet_guard._kuchnia_errors import KuchniaError
|
||||||
|
from diet_guard.tests._kuchnia_fakes import (
|
||||||
|
FakeResponse,
|
||||||
|
FakeSession,
|
||||||
|
fake_requests,
|
||||||
|
write_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CSRF = "csrf-123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def creds() -> None:
|
||||||
|
"""Write a credentials file at the (redirected) config path."""
|
||||||
|
write_credentials(_kuchnia_config.KUCHNIA_CREDENTIALS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_403() -> FakeResponse:
|
||||||
|
return FakeResponse(403, text="", cookies={"XSRF-TOKEN": _CSRF})
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticate(session: FakeSession) -> None:
|
||||||
|
with patch.object(_kuchnia_client, "requests", fake_requests(session)):
|
||||||
|
PanelSession().authenticate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_csrf_403_is_retried_echoing_the_token() -> None:
|
||||||
|
session = FakeSession([_csrf_403(), FakeResponse(200, {})])
|
||||||
|
_authenticate(session)
|
||||||
|
assert [call[0] for call in session.calls] == ["POST", "POST"]
|
||||||
|
assert "X-XSRF-TOKEN" not in session.sent_headers[0]
|
||||||
|
assert session.sent_headers[1]["X-XSRF-TOKEN"] == _CSRF
|
||||||
|
assert _kuchnia_config.load_session_cookie() == "session-abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wrong_password_behind_csrf_still_names_the_password() -> None:
|
||||||
|
session = FakeSession([_csrf_403(), FakeResponse(401, {})])
|
||||||
|
with pytest.raises(KuchniaError, match=r"HTTP 401.*check the password"):
|
||||||
|
_authenticate(session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_403_blames_the_panel_not_the_password() -> None:
|
||||||
|
session = FakeSession([_csrf_403(), _csrf_403()])
|
||||||
|
with pytest.raises(KuchniaError, match="not a password problem") as raised:
|
||||||
|
_authenticate(session)
|
||||||
|
assert "HTTP 403" in str(raised.value)
|
||||||
|
assert len(session.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_403_without_a_token_is_not_retried() -> None:
|
||||||
|
session = FakeSession([FakeResponse(403, text="")])
|
||||||
|
with pytest.raises(KuchniaError, match="not a password problem"):
|
||||||
|
_authenticate(session)
|
||||||
|
assert len(session.calls) == 1
|
||||||
@ -72,9 +72,9 @@ Three traps worth knowing before touching `_kuchnia_orders.py`:
|
|||||||
and the one the bundles pass a `deliveryId` to. The menu endpoint above is
|
and the one the bundles pass a `deliveryId` to. The menu endpoint above is
|
||||||
the one that answers.
|
the one that answers.
|
||||||
- **`deliveryId` is opaque, never a date.** The date form 400s.
|
- **`deliveryId` is opaque, never a date.** The date form 400s.
|
||||||
- **No `XSRF-TOKEN` cookie is issued**, so the CSRF echo the panel's own
|
- **CSRF is enforced at login since ~2026-10.** No `X-XSRF-TOKEN` → a bare
|
||||||
JavaScript performs is a no-op here. The client skips it when absent rather
|
403 that sets the `XSRF-TOKEN` cookie; both clients retry once echoing it. A
|
||||||
than sending an empty header.
|
login 403 is never a wrong password (that is 401). Logins rate-limit (429).
|
||||||
|
|
||||||
### Macros are per portion
|
### Macros are per portion
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user