diff --git a/.github/workflows/compat-auth.yml b/.github/workflows/compat-auth.yml new file mode 100644 index 0000000..a9c6614 --- /dev/null +++ b/.github/workflows/compat-auth.yml @@ -0,0 +1,117 @@ +name: Signed-in compatibility + +# compat.yml checks what anyone can see on freshharvest.com. Everything that has +# actually broken so far sat behind the login, and every one of those breaks was +# SILENT: subscription rows moved and the integration reported 0 subscriptions, +# hold dates stopped being ISO and it reported 0 holds. A sensor reading 0 looks +# like an account with nothing in it, so nobody notices. +# +# tools/compat_auth.py signs in, read-only, and checks the markup the sensors and +# controls are parsed from. It never posts to a write endpoint. +# +# WHERE IT RUNS: only on the maintainer's own forge, where the account +# credentials are repository secrets. The GitHub mirror and forks skip it (the +# job's `if:`); they have no credentials, and a daily red run would be noise. +# +# THE RUN LOG IS PUBLIC. The script prints one pass/fail label per assumption and +# nothing read from the account. Keep it that way: no `set -x`, never echo an +# env var, and hand secrets to steps through `env:` only, never inline in `run:`. +# +# Secrets: FRESHHARVEST_EMAIL and FRESHHARVEST_PASSWORD (the account), NTFY_URL +# (the full ntfy topic URL) and NTFY_TOKEN (an access token for that topic). + +on: + schedule: + # 11:41 UTC is 07:41 EDT (06:41 EST): before the day's first Home Assistant + # refresh, so a break is known before anyone reads a sensor quietly showing 0. + - cron: "41 11 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + compat-auth: + name: signed-in markup check + # Not GitHub, and this repository: skips the mirror and every fork. + if: ${{ github.server_url != 'https://github.com' && github.repository == 'flan/ha-freshharvest' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + # Exit 5: every assumption holds. 10: drift. Anything else: it could not run. + - name: Check the markup behind the login + id: check + shell: bash + env: + FH_EMAIL: ${{ secrets.FRESHHARVEST_EMAIL }} + FH_PASSWORD: ${{ secrets.FRESHHARVEST_PASSWORD }} + run: | + report="${RUNNER_TEMP:-/tmp}/compat-auth-report.txt" + rc=0 + # Well inside the job's 10 minutes, so the notify step still gets to run. + timeout 7m python tools/compat_auth.py > "$report" 2>&1 || rc=$? + if [ "$rc" -eq 124 ]; then + printf '\ntimed out after 7 minutes\n' >> "$report" + fi + cat "$report" + echo "rc=$rc" >> "$GITHUB_OUTPUT" + case "$rc" in + 5) exit 0 ;; + 10) echo "::error::freshharvest.com markup has drifted; see the report above" + exit 1 ;; + *) echo "::error::the check could not run (exit $rc)" + exit 1 ;; + esac + + # Runs on drift, on a failed check, and on any earlier step failing. + - name: Push the report to ntfy + if: ${{ failure() }} + shell: bash + env: + NTFY_URL: ${{ secrets.NTFY_URL }} + NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }} + RC: ${{ steps.check.outputs.rc }} + # Gitea addresses a run's page by its per-repository run number. + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }} + run: | + if [ -z "$NTFY_URL" ] || [ -z "$NTFY_TOKEN" ]; then + echo "::error::the NTFY_URL and NTFY_TOKEN secrets must both be set" + exit 1 + fi + report="${RUNNER_TEMP:-/tmp}/compat-auth-report.txt" + if [ ! -s "$report" ]; then + echo "No report: a step before the check failed." > "$report" + fi + if [ "$RC" = "10" ]; then + title="Fresh Harvest markup drift" + priority=default + tags=warning + else + title="Fresh Harvest signed-in check could not run" + priority=high + tags=rotating_light + fi + # -s without -S: a curl error message would name the ntfy host in this public log. + # The response body echoes the report, so it goes to /dev/null. + curl_rc=0 + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 --retry 2 \ + -H "Authorization: Bearer $NTFY_TOKEN" \ + -H "Title: $title" \ + -H "Priority: $priority" \ + -H "Tags: $tags" \ + -H "Click: $RUN_URL" \ + --data-binary "@$report" \ + "$NTFY_URL") || curl_rc=$? + if [ "$code" != "200" ]; then + echo "::error::ntfy push failed (HTTP ${code:-none}, curl exit $curl_rc)" + exit 1 + fi + echo "report pushed to ntfy" diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 62f00bc..7cdbc71 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -9,11 +9,11 @@ name: Upstream compatibility # live site daily, refreshing the matrix in README.md and opening an issue when # something breaks. # -# SCOPE: unauthenticated surface only. The authenticated contract (dashboard -# markup, cart hashes, skip popups, subscribe forms) needs a real session, and -# the only way to give public CI one is to park a personal grocery account's -# password in repo secrets. Not worth it for a drift check — that half belongs in -# a fleet job on a host that already holds credentials. +# SCOPE: unauthenticated surface only, so this runs anywhere, the GitHub mirror +# included. The authenticated contract (dashboard markup, cart hashes, skip +# popups, subscribe forms) needs a real session: compat-auth.yml checks that +# half, and runs only on the maintainer's forge, where the account credentials +# are repository secrets. on: schedule: diff --git a/CHANGELOG.md b/CHANGELOG.md index 32eb473..fe13a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.1] - 2026-09-21 + +No change to the integration itself: the check of the markup behind the login +now lives in this repository. + +### Added + +- `tools/compat_auth.py` and a daily `compat-auth.yml` workflow. It signs in, + read-only, and checks the 17 assumptions the integration makes about the + signed-in pages. Exit 5 means all hold, 10 means drift, and anything else + means it could not run; a failed run pushes the report to ntfy. It runs only + on the maintainer's forge and is skipped on the GitHub mirror and on forks. + Credentials come from the `FH_EMAIL` and `FH_PASSWORD` environment + variables, and the output is pass/fail labels only, because the run log is + public. +- Tests for its credential handling, exit codes and log hygiene. +- A README section on the scheduled sign-in check. + +### Changed + +- `compat.yml`, `tools/compat.py` and `docs/internals.md` point at the new + workflow for the signed-in half instead of an external job. + +### Fixed + +- A broken relative link to `tools/compat.py` in `docs/internals.md`. + ## [0.5.0] - 2026-08-19 The integration now wears the Fresh Harvest brand instead of the generic diff --git a/README.md b/README.md index b5f0401..1a5071c 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,23 @@ pip install beautifulsoup4 pytest yarl pytest tests/ ``` +## Scheduled sign-in check + +Every break so far has been behind the login, and every one was silent: a +sensor reading a plausible `0` rather than going unavailable. So once a day +[tools/compat_auth.py](tools/compat_auth.py) signs in to a real account, +read-only, and checks the markup the sensors and controls are parsed from: +the dashboard, subscriptions, vacation holds, and the popups behind skip, +donate and add. When something has moved, the run fails and the maintainer +gets a push naming the broken assumption and what it would break. + +It runs only on the maintainer's own forge, where the account credentials +are, and is skipped on the GitHub mirror and on forks. Its log is public, so +it prints a pass or fail per assumption and nothing about the account. To run +it yourself, set the four secrets listed at the top of +[.github/workflows/compat-auth.yml](.github/workflows/compat-auth.yml) and +change the job's `if:`. + ## Disclaimer Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest. diff --git a/custom_components/freshharvest/manifest.json b/custom_components/freshharvest/manifest.json index da5c6b9..9b1759f 100644 --- a/custom_components/freshharvest/manifest.json +++ b/custom_components/freshharvest/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "beautifulsoup4>=4.12" ], - "version": "0.5.0" + "version": "0.5.1" } diff --git a/docs/internals.md b/docs/internals.md index f0b86d3..eed6994 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -67,7 +67,12 @@ Two invariants hold against the portal's own arithmetic, and tests assert both: freshharvest.com has no API and no stability contract — this integration reads HTML and posts to form endpoints, so a redesign can change what a value *means* -without changing its shape. [tools/compat.py](tools/compat.py) records every +without changing its shape. [tools/compat.py](../tools/compat.py) records every assumption and CI asserts them against the live site daily, refreshing this table and opening an issue on drift. +That covers only what anyone can see. The markup behind the login, where every +real break so far has been, is checked by +[tools/compat_auth.py](../tools/compat_auth.py) from a daily workflow that runs +only on the maintainer's forge; see the README. + diff --git a/tests/test_compat_auth.py b/tests/test_compat_auth.py new file mode 100644 index 0000000..4f5fde2 --- /dev/null +++ b/tests/test_compat_auth.py @@ -0,0 +1,288 @@ +"""Tests for tools/compat_auth.py, the signed-in drift check, with no network. + +Its log is public, so besides the exit codes these pin down what it must never +print: the credentials, and anything that reveals the account's state. +""" + +from __future__ import annotations + +import importlib.util +import re +import urllib.error +import urllib.parse +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parent.parent +WORKFLOW = ROOT / ".github" / "workflows" / "compat-auth.yml" +SCRIPT = ROOT / "tools" / "compat_auth.py" + +_spec = importlib.util.spec_from_file_location("compat_auth", SCRIPT) +compat_auth = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(compat_auth) + +EMAIL = "account-under-test" +PASSWORD = "correct horse battery staple" +ENV = {"FH_EMAIL": EMAIL, "FH_PASSWORD": PASSWORD} + +HEALTHY = { + "/s/popup/login": ( + "" + "" + ), + "/p/dashboard/details": ( + "Your deliveries are Tuesdays. Next Arriving: Tue, Aug 4" + "
" + "$66.16" + "
Shop tomorrow
" + "
" + ), + "/p/dashboard/manage-subscriptions": ( + "
" + "Bananas" + "1
" + ), + "/p/dashboard/pause-deliveries": ( + "
" + "

Upcoming Pauses

None scheduled

" + ), + "/p/dashboard/manage-orders": ( + 'openPopup("pause-delivery", "a") openPopup("donate-delivery", "b")' + ), + "/p/shop/basket-types/georgia-grown-baskets": ( + "
" + "Choose
" + ), + "/p/shop/item/6744/bananas": ( + 'orderManage("add","d")' + "" + ), +} +SIGNED_IN = "Sign Out" + + +class FakePortal: + """Serves canned pages and records every form post.""" + + def __init__(self, pages=None, login_reply=SIGNED_IN, raise_on=None): + self.pages = {**HEALTHY, **(pages or {})} + self.login_reply = login_reply + self.raise_on = raise_on or {} + self.posts: list[tuple[str, dict]] = [] + + def get(self, path): + if path in self.raise_on: + raise self.raise_on[path] + return self.pages[path] + + def post(self, path, fields): + self.posts.append((path, dict(fields))) + return self.login_reply + + +def run(capsys, portal=None, env=ENV): + portal = portal or FakePortal() + code = compat_auth.main(env=env, portal_factory=lambda: portal) + out = capsys.readouterr().out + assert EMAIL not in out and PASSWORD not in out + return code, out, portal + + +# --- environment ------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("env", "named"), + [ + ({}, ["FH_EMAIL", "FH_PASSWORD"]), + ({"FH_EMAIL": EMAIL}, ["FH_PASSWORD"]), + ({"FH_PASSWORD": PASSWORD}, ["FH_EMAIL"]), + ({"FH_EMAIL": " ", "FH_PASSWORD": "\n"}, ["FH_EMAIL", "FH_PASSWORD"]), + ], +) +def test_missing_credentials_exit_2_without_touching_the_network(capsys, env, named): + def no_network(): + raise AssertionError("must not open a session without credentials") + + code = compat_auth.main(env=env, portal_factory=no_network) + out = capsys.readouterr().out + assert code == compat_auth.MISCONFIGURED == 2 + for name in named: + assert name in out + assert EMAIL not in out and PASSWORD not in out + + +def test_reads_the_process_environment_by_default(capsys, monkeypatch): + monkeypatch.delenv("FH_EMAIL", raising=False) + monkeypatch.delenv("FH_PASSWORD", raising=False) + assert compat_auth.main() == 2 + + +def test_credentials_are_stripped(): + assert compat_auth.credentials( + {"FH_EMAIL": f" {EMAIL}\n", "FH_PASSWORD": f"{PASSWORD}\n"} + ) == (EMAIL, PASSWORD) + + +# --- exit-code mapping --------------------------------------------------------- + + +def test_all_seventeen_assumptions_hold_exit_5(capsys): + code, out, portal = run(capsys) + assert code == compat_auth.QUIET == 5 + assert "17/17 assumptions hold" in out + assert "FAIL" not in out + # The credentials went to the login POST, with the tokens unquoted. + [(path, fields)] = portal.posts + assert path == "/s/submit/login" + assert fields["LoginEmail"] == EMAIL + assert fields["LoginPassword"] == PASSWORD + assert fields["LoginSecurity"] == "sec==" + + +def test_drift_exit_10_names_the_assumption_and_symptom(capsys): + portal = FakePortal(pages={"/p/dashboard/manage-subscriptions": "
"}) + code, out, _ = run(capsys, portal) + assert code == compat_auth.FINDING == 10 + assert "[FAIL] subscription rows are .account-item-container" in out + assert "reports 0 subscriptions" in out + assert "15/17 assumptions hold" in out # rows and cells both went + + +def test_rejected_sign_in_is_drift_and_still_prints_the_report(capsys): + code, out, _ = run(capsys, FakePortal(login_reply="

Invalid login

")) + assert code == 10 + assert "[FAIL] credentials accepted" in out + assert "Sign-in failed" in out + + +def test_login_form_without_tokens_is_drift(capsys): + portal = FakePortal(pages={"/s/popup/login": "
"}) + code, out, _ = run(capsys, portal) + assert code == 10 + assert "[FAIL] login form mints both anti-replay tokens" in out + assert portal.posts == [] + + +@pytest.mark.parametrize( + "error", + [ + urllib.error.URLError("Name or service not known"), + urllib.error.HTTPError(compat_auth.BASE, 503, "Service Unavailable", None, None), + TimeoutError("timed out"), + ConnectionResetError("reset by peer"), + ], +) +def test_unreachable_portal_exit_1(capsys, error): + portal = FakePortal(raise_on={"/p/dashboard/details": error}) + code, out, _ = run(capsys, portal) + assert code == compat_auth.CANNOT_RUN == 1 + assert "could not reach the portal" in out + + +def test_network_error_text_is_redacted(capsys): + error = OSError(f"refused for {EMAIL}") + code, out, _ = run(capsys, FakePortal(raise_on={"/s/popup/login": error})) + assert code == 1 + assert "***" in out + + +def test_unexpected_error_exit_1_names_only_the_type(capsys): + error = ValueError(f"state dump {EMAIL} {PASSWORD}") + code, out, _ = run(capsys, FakePortal(raise_on={"/p/dashboard/details": error})) + assert code == 1 + assert "ValueError" in out + assert "state dump" not in out + + +def test_exit_code_ignores_checks_with_nothing_to_assert(): + assert compat_auth.exit_code([("a", True, ""), ("b", compat_auth.SKIP, "")]) == 5 + assert compat_auth.exit_code([("a", True, ""), ("b", False, "x")]) == 10 + + +# --- what a public log may reveal --------------------------------------------- + + +def test_a_scheduled_hold_is_indistinguishable_from_none(capsys): + none = run(capsys)[1] + hold = run(capsys, FakePortal(pages={"/p/dashboard/pause-deliveries": ( + "
" + "

Upcoming Pauses

Tuesday, Sep 8 - Tuesday, Sep 15

" + )}))[1] + assert none == hold + assert "n/a" not in none and "not applicable" not in none + + +def test_iso_hold_dates_are_drift(capsys): + code, out, _ = run(capsys, FakePortal(pages={"/p/dashboard/pause-deliveries": ( + "

Upcoming Pauses

" + "

Tuesday, Sep 8 - Tuesday, Sep 15 (2026-09-08)

" + )})) + assert code == 10 + assert "[FAIL] hold ranges are day-name + abbreviated month, not ISO" in out + + +def test_portal_posts_urlencoded_to_the_site(): + sent = [] + + class StubResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return b"Sign Out" + + class StubOpener: + def open(self, req, timeout): + sent.append((req, timeout)) + return StubResponse() + + portal = compat_auth.Portal() + portal._opener = StubOpener() + assert portal.post("/s/submit/login", {"LoginEmail": EMAIL}) == "Sign Out" + req, timeout = sent[0] + assert req.full_url == "https://freshharvest.com/s/submit/login" + assert urllib.parse.parse_qs(req.data.decode()) == {"LoginEmail": [EMAIL]} + assert timeout == compat_auth.TIMEOUT + + +# --- the workflow ------------------------------------------------------------- + + +def test_workflow_lives_where_gitea_reads_it(): + # Gitea reads only the FIRST of .gitea/workflows and .github/workflows that + # exists; creating .gitea/workflows would silently stop every workflow here. + assert WORKFLOW.is_file() + assert not (ROOT / ".gitea" / "workflows").exists() + + +def test_workflow_shape(): + yaml = pytest.importorskip("yaml") + doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + triggers = doc.get("on", doc.get(True)) # YAML 1.1 reads a bare `on` as True + assert set(triggers) == {"schedule", "workflow_dispatch"} + assert triggers["schedule"] == [{"cron": "41 11 * * *"}] + assert doc["permissions"] == {"contents": "read"} + [job] = doc["jobs"].values() + assert job["timeout-minutes"] == 10 + assert "github.server_url" in job["if"] + + +def test_workflow_and_script_keep_private_details_out(): + text = WORKFLOW.read_text(encoding="utf-8") + code = "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + assert "set -x" not in code and "xtrace" not in code + assert set(re.findall(r"secrets\.(\w+)", text)) == { + "FRESHHARVEST_EMAIL", "FRESHHARVEST_PASSWORD", "NTFY_URL", "NTFY_TOKEN", + } + for source in (text, SCRIPT.read_text(encoding="utf-8")): + assert not re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", source), "an email address" + assert not re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", source), "an IP address" + hosts = set(re.findall(r"https?://([^/\s'\"]+)", source)) + assert hosts <= {"freshharvest.com", "github.com"}, hosts diff --git a/tools/compat.py b/tools/compat.py index 16e0457..78007fa 100644 --- a/tools/compat.py +++ b/tools/compat.py @@ -10,11 +10,10 @@ schedule and fails when the site moves. WHAT THIS CAN AND CANNOT SEE ---------------------------- Only the *unauthenticated* surface is checked here: the login handshake and the -Algolia catalogue. The authenticated contract — dashboard markup, cart add -hashes, skip popups, subscribe forms — needs a real session, and the only way to -give public CI one is to put a personal grocery account's password in repo -secrets. That is not worth it for a drift check. Those assumptions belong in a -fleet job on a host that already has credential access; see README. +Algolia catalogue, so it runs anywhere. The authenticated contract — dashboard +markup, cart add hashes, skip popups, subscribe forms — needs a real session. +tools/compat_auth.py checks that half, from a workflow that runs only on the +maintainer's forge, where the account credentials are; see README. Exit code is the number of FAILED checks, so CI fails loudly on drift. """ diff --git a/tools/compat_auth.py b/tools/compat_auth.py new file mode 100755 index 0000000..811e6c2 --- /dev/null +++ b/tools/compat_auth.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Check the markup behind the freshharvest.com login that this integration parses. + +tools/compat.py covers what anyone can see: the sign-in form, the catalogue and +the endpoint shapes. Everything that has actually broken so far sat behind the +login, and every one of those breaks was SILENT: + + * subscription rows moved -> reported 0 subscriptions + * hold dates were not ISO -> reported 0 holds + * a popup gained a space -> matched nothing at all + +A sensor reading 0 looks the same as an account with nothing in it, so nobody +notices. This signs in and asserts each of those assumptions, naming the +symptom when one breaks. + +Read-only: it signs in and reads pages, and never posts to a write endpoint. + +CREDENTIALS come from the environment, FH_EMAIL and FH_PASSWORD. It refuses to +run without both. + +THE OUTPUT IS FOR A PUBLIC LOG (.github/workflows/compat-auth.yml). It prints +one pass/fail label per assumption and nothing read from the account: no +email, no page content, and no hint of the account's state. A check with +nothing to assert against (the hold-date format when no hold is scheduled) +prints as passing, because "no hold scheduled" in a public log would announce +when deliveries are paused. + +Exit codes: + 5 every assumption holds (ran, nothing to report) + 10 at least one assumption no longer holds: drift + 2 FH_EMAIL or FH_PASSWORD is missing + 1 the check could not run (portal unreachable, timeout, unexpected error) +""" + +from __future__ import annotations + +import os +import re +import sys +import urllib.parse +import urllib.request +from collections.abc import Callable, Mapping +from http.cookiejar import CookieJar + +BASE = "https://freshharvest.com" +UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0 Safari/537.36" +) +TIMEOUT = 45 + +QUIET = 5 # ran, every assumption holds +FINDING = 10 # ran, and something moved +CANNOT_RUN = 1 +MISCONFIGURED = 2 + +# A check with nothing to assert against. Rendered as a pass; see the docstring. +SKIP = "skip" + +Row = tuple[str, object, str] # (assumption, True | False | SKIP, symptom) + + +class ConfigError(Exception): + """The credentials are missing from the environment.""" + + +def credentials(env: Mapping[str, str]) -> tuple[str, str]: + """Return (email, password) from FH_EMAIL / FH_PASSWORD, or raise ConfigError.""" + email = env.get("FH_EMAIL", "").strip() + password = env.get("FH_PASSWORD", "").strip() + missing = [ + name + for name, value in (("FH_EMAIL", email), ("FH_PASSWORD", password)) + if not value + ] + if missing: + verb = "is" if len(missing) == 1 else "are" + raise ConfigError(f"{' and '.join(missing)} {verb} not set or empty") + return email, password + + +class Portal: + """A cookie-carrying session on freshharvest.com. The only network code here.""" + + def __init__(self) -> None: + self._opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(CookieJar()) + ) + + def _open(self, path: str, data: bytes | None = None) -> str: + req = urllib.request.Request( + BASE + path, data=data, headers={"User-Agent": UA} + ) + with self._opener.open(req, timeout=TIMEOUT) as resp: + return resp.read().decode("utf-8", "replace") + + def get(self, path: str) -> str: + return self._open(path) + + def post(self, path: str, fields: Mapping[str, str]) -> str: + return self._open(path, urllib.parse.urlencode(fields).encode()) + + +def sign_in(portal: Portal, email: str, password: str, rows: list[Row]) -> bool: + form = portal.get("/s/popup/login") + hidden = dict( + re.findall(r"name='(LoginSecurity|SubmitToken)'[^>]*value='([^']*)'", form) + ) + if len(hidden) != 2: + rows.append(("login form mints both anti-replay tokens", False, + "sign-in breaks entirely")) + return False + rows.append(("login form mints both anti-replay tokens", True, "")) + body = portal.post("/s/submit/login", { + "LoginEmail": email, + "LoginPassword": password, + "LoginSecurity": urllib.parse.unquote(hidden["LoginSecurity"]), + "SubmitToken": urllib.parse.unquote(hidden["SubmitToken"]), + "Redirect": "", + }) + ok = "sign out" in body.lower() + rows.append(("credentials accepted", ok, "every entity goes unavailable")) + return ok + + +def run_checks(portal: Portal, email: str, password: str) -> list[Row]: + rows: list[Row] = [] + + def check(assumption: str, ok: object, symptom: str = "") -> None: + rows.append((assumption, ok, symptom)) + + if not sign_in(portal, email, password, rows): + return rows + + # --- the dashboard the sensors are built on -------------------------- + dash = portal.get("/p/dashboard/details") + check("dashboard states the delivery day and next arrival", + bool(re.search(r"Your deliveries are\s*\w+\.\s*Next Arriving:", dash)), + "next-delivery date goes unknown") + check("carts render as div.cart-contents[data-cart-select]", + "cart-contents" in dash and "data-cart-select" in dash, + "no orders parsed: every order sensor goes unknown") + check("order totals render as #OrderTotals-", + "OrderTotals-" in dash, + "totals go unknown while dates still work") + check("the shopping window lives in .cart-customize-wrapper", + "cart-customize-wrapper" in dash, + "every order looks locked; skip and add refuse") + check("free-delivery progress carries a max", + bool(re.search(r"]+max='[\d.]+'", dash)), + "free-delivery-remaining goes unknown") + + # --- subscriptions: the row selector that silently returned zero ----- + subs = portal.get("/p/dashboard/manage-subscriptions") + check("subscription rows are .account-item-container", + "account-item-container" in subs, + "reports 0 subscriptions, which looks like having none") + check("subscription cells keep their semantic classes", + "account-item-description" in subs and "account-item-history-qty" in subs, + "subscription names/quantities go blank") + + # --- vacation holds: the date format that silently returned zero ----- + pause = portal.get("/p/dashboard/pause-deliveries") + check("the vacation hold form still posts to pause-range-add", + "/s/submit/pause-range-add" in pause, + "cannot schedule a hold") + # Only assertable while a hold exists. With none scheduled there is no date + # to inspect, and an unbounded search past "Upcoming Pauses" matches an ISO + # date from anywhere else on the page: a check that fails on a healthy + # account is worse than no check, because it trains you to ignore it. + section = re.search( + r"Upcoming Pauses(.{0,400}?)(?:Close Account| int: + """A count of broken assumptions is not an exit code: 10 is drift, 5 is all clear.""" + return FINDING if any(ok is False for _, ok, _ in rows) else QUIET + + +def render(rows: list[Row]) -> str: + """Pass/fail labels only. SKIP renders exactly like a pass (see the docstring).""" + signed_in = any(a == "credentials accepted" and ok is True for a, ok, _ in rows) + width = max(len(a) for a, _, _ in rows) + lines = ["Fresh Harvest signed-in markup check", ""] + for assumption, ok, symptom in rows: + lines.append(f" [{'FAIL' if ok is False else 'ok '}] {assumption.ljust(width)}") + if ok is False and symptom: + lines.append(f" -> {symptom}") + passed = sum(1 for _, ok, _ in rows if ok is not False) + lines += ["", f"{passed}/{len(rows)} assumptions hold"] + if not signed_in: + lines.append("Sign-in failed, so nothing behind the login was checked.") + elif exit_code(rows) == FINDING: + lines.append("ha-freshharvest is probably reporting wrong values, not erroring.") + return "\n".join(lines) + + +def redact(text: str, *secrets: str) -> str: + """Belt and braces for a public log: no credential survives into any output.""" + for secret in secrets: + if secret: + text = text.replace(secret, "***") + return text + + +def main( + env: Mapping[str, str] | None = None, + portal_factory: Callable[[], Portal] = Portal, +) -> int: + try: + email, password = credentials(os.environ if env is None else env) + except ConfigError as err: + print(f"cannot run: {err}") + return MISCONFIGURED + + try: + rows = run_checks(portal_factory(), email, password) + except OSError as err: # URLError, HTTPError, timeouts, resets + print(redact(f"could not reach the portal: {err}", email, password)) + return CANNOT_RUN + except Exception as err: # noqa: BLE001 -- public log: name it, never dump state + print(f"the check itself failed: {type(err).__name__}") + return CANNOT_RUN + + print(redact(render(rows), email, password)) + return exit_code(rows) + + +if __name__ == "__main__": + sys.exit(main())