Check the signed-in markup from a scheduled workflow
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:
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"requirements": [
|
||||
"beautifulsoup4>=4.12"
|
||||
],
|
||||
"version": "0.5.0"
|
||||
"version": "0.5.1"
|
||||
}
|
||||
|
||||
+6
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
Executable
+268
@@ -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())
|
||||
Reference in New Issue
Block a user