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
|
# live site daily, refreshing the matrix in README.md and opening an issue when
|
||||||
# something breaks.
|
# something breaks.
|
||||||
#
|
#
|
||||||
# SCOPE: unauthenticated surface only. The authenticated contract (dashboard
|
# SCOPE: unauthenticated surface only, so this runs anywhere, the GitHub mirror
|
||||||
# markup, cart hashes, skip popups, subscribe forms) needs a real session, and
|
# included. The authenticated contract (dashboard markup, cart hashes, skip
|
||||||
# the only way to give public CI one is to park a personal grocery account's
|
# popups, subscribe forms) needs a real session: compat-auth.yml checks that
|
||||||
# password in repo secrets. Not worth it for a drift check — that half belongs in
|
# half, and runs only on the maintainer's forge, where the account credentials
|
||||||
# a fleet job on a host that already holds credentials.
|
# are repository secrets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
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/),
|
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).
|
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
|
## [0.5.0] - 2026-08-19
|
||||||
|
|
||||||
The integration now wears the Fresh Harvest brand instead of the generic
|
The integration now wears the Fresh Harvest brand instead of the generic
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ pip install beautifulsoup4 pytest yarl
|
|||||||
pytest tests/
|
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
|
## Disclaimer
|
||||||
|
|
||||||
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
Unofficial and unaffiliated — not endorsed by or supported by Fresh Harvest.
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"beautifulsoup4>=4.12"
|
"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
|
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*
|
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
|
assumption and CI asserts them against the live site daily, refreshing this
|
||||||
table and opening an issue on drift.
|
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
|
WHAT THIS CAN AND CANNOT SEE
|
||||||
----------------------------
|
----------------------------
|
||||||
Only the *unauthenticated* surface is checked here: the login handshake and the
|
Only the *unauthenticated* surface is checked here: the login handshake and the
|
||||||
Algolia catalogue. The authenticated contract — dashboard markup, cart add
|
Algolia catalogue, so it runs anywhere. The authenticated contract — dashboard
|
||||||
hashes, skip popups, subscribe forms — needs a real session, and the only way to
|
markup, cart add hashes, skip popups, subscribe forms — needs a real session.
|
||||||
give public CI one is to put a personal grocery account's password in repo
|
tools/compat_auth.py checks that half, from a workflow that runs only on the
|
||||||
secrets. That is not worth it for a drift check. Those assumptions belong in a
|
maintainer's forge, where the account credentials are; see README.
|
||||||
fleet job on a host that already has credential access; see README.
|
|
||||||
|
|
||||||
Exit code is the number of FAILED checks, so CI fails loudly on drift.
|
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