Check the signed-in markup from a scheduled workflow
Validate / hassfest (push) Skipped
Validate / HACS (push) Skipped
Upstream compatibility / compat (push) Successful in 8s
Validate / pytest (push) Successful in 11s

Move the authenticated drift check into the repository as
tools/compat_auth.py, run daily by .github/workflows/compat-auth.yml on the
maintainer's forge only. Credentials come from FH_EMAIL and FH_PASSWORD, the
output is pass/fail labels only because the run log is public, and a failed
run pushes the report to ntfy. Exit 5 means all hold, 10 drift, anything
else that it could not run.

Point compat.yml, tools/compat.py and the docs at it, add a README section,
and cut 0.5.1.
This commit is contained in:
flan
2026-09-21 19:01:13 +00:00
parent bf01fd6eb0
commit 22e6499a8e
9 changed files with 733 additions and 12 deletions
+117
View File
@@ -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"
+5 -5
View File
@@ -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:
+27
View File
@@ -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
+17
View File
@@ -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.
+1 -1
View File
@@ -12,5 +12,5 @@
"requirements": [
"beautifulsoup4>=4.12"
],
"version": "0.5.0"
"version": "0.5.1"
}
+6 -1
View File
@@ -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.
+288
View File
@@ -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": (
"<input type='hidden' name='LoginSecurity' value='sec%3D%3D'>"
"<input type='hidden' name='SubmitToken' value='tok'>"
),
"/p/dashboard/details": (
"Your deliveries are Tuesdays. Next Arriving: Tue, Aug 4"
"<div class='cart-contents' data-cart-select='1'>"
"<span id='OrderTotals-123'>$66.16</span>"
"<div class='cart-customize-wrapper'>Shop tomorrow</div>"
"<progress value='38.42' max='50.00'></progress></div>"
),
"/p/dashboard/manage-subscriptions": (
"<div class='account-item-container'>"
"<span class='account-item-description'>Bananas</span>"
"<span class='account-item-history-qty'>1</span></div>"
),
"/p/dashboard/pause-deliveries": (
"<form action='/s/submit/pause-range-add'></form>"
"<h3>Upcoming Pauses</h3><p>None scheduled</p></section>"
),
"/p/dashboard/manage-orders": (
'openPopup("pause-delivery", "a") openPopup("donate-delivery", "b")'
),
"/p/shop/basket-types/georgia-grown-baskets": (
"<div data-title='basket-12'>"
"<a onclick='openPopup(\"select-basket\", \"c\")'>Choose</a></div>"
),
"/p/shop/item/6744/bananas": (
'orderManage("add","d")'
"<form action='/s/submit/item-frequency' class='popup-toggle'></form>"
),
}
SIGNED_IN = "<a href='/logout'>Sign Out</a>"
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": "<table></table>"})
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="<p>Invalid login</p>"))
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": "<form></form>"})
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": (
"<form action='/s/submit/pause-range-add'></form>"
"<h3>Upcoming Pauses</h3><p>Tuesday, Sep 8 - Tuesday, Sep 15</p></section>"
)}))[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": (
"<form action='/s/submit/pause-range-add'></form><h3>Upcoming Pauses</h3>"
"<p>Tuesday, Sep 8 - Tuesday, Sep 15 (2026-09-08)</p></section>"
)}))
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
+4 -5
View File
@@ -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.
"""
+268
View File
@@ -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-<id>",
"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"<progress[^>]+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|</section)", pause, re.DOTALL
)
body = section.group(1) if section else ""
entries = re.findall(
r"[A-Z][a-z]+,\s*[A-Z][a-z]{2}\s+\d{1,2}\s*-\s*[A-Z][a-z]+,\s*[A-Z][a-z]{2}\s+\d{1,2}",
body,
)
check("hold ranges are day-name + abbreviated month, not ISO",
SKIP if not entries else not re.search(r"\d{4}-\d{2}-\d{2}", body),
"hold parsing silently reports none")
# --- popups behind every write action --------------------------------
orders = portal.get("/p/dashboard/manage-orders")
for kind, symptom in (
("pause-delivery", "skip switch cannot find a delivery"),
("donate-delivery", "donate button fails"),
):
check(f"{kind} popup is still offered",
bool(re.search(rf'openPopup\("{kind}"', orders)),
symptom)
# --- basket switching -------------------------------------------------
baskets = portal.get("/p/shop/basket-types/georgia-grown-baskets")
check("basket options offer a select-basket popup",
bool(re.search(r'openPopup\("select-basket",\s*"', baskets)),
"produce-box select shows no options")
check("basket ids are exposed as data-title='basket-<id>'",
"data-title='basket-" in baskets,
"cannot identify which box is which")
# --- add-to-cart hash -------------------------------------------------
item = portal.get("/p/shop/item/6744/bananas")
check("orderable items expose an orderManage add hash",
bool(re.search(r'orderManage\("add","', item)),
"adding any item fails as though out of stock")
check("subscribe form posts to item-frequency with popup-toggle",
"/s/submit/item-frequency" in item and "popup-toggle" in item,
"subscribing silently does nothing")
return rows
def exit_code(rows: list[Row]) -> 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())