Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ do not reopen it expecting a better parser to help (the #83 pattern).** First li
- **Recursively expanded, and surfaced in the export (#123).** `index_zip` descends nested zips to any depth (guarded: `_MAX_ZIP_DEPTH` 8, `_MAX_ZIP_ENTRIES` 10000; a corrupt/encrypted/empty nested zip degrades to an opaque leaf, the entries budget is a hard truncation backstop). Real bundles hold zips within zips, so the 33 captured solicitations expand from 520 top-level entries to **1,111 actual documents**. `export/document.py` nests a per-solicitation `documents` array unioning these leaves with the **Award Summary Forms** (`background_pdf` kind='award_summary') — which surfaces those **229 forms for the first time**: they key on `document_number`, but the export otherwise buckets `background_pdf` by council `reference`, which they lack. Entry shape `{source, name, path, type, size_bytes, url}` — internal hashes stay private; award-summary carries its City URL, Ariba files carry none (bytes unpublished). Full-text/OCR are deferred.
- **Staff reports join via the bid-bridge (#126).** The per-solicitation `documents` array also folds in **staff-report PDFs** (`background_pdf` kind='bgrd') through an **exact** council-`reference` ↔ `document_number` link: an Ariba-era Bid Award Panel agenda names both, so a single `bid` row carries both, and `export/document.py` derives the `reference → document_number` map from `bid` at query time (no table). No fuzzy matching, no false-positive surface — the opposite of #77's supplier+amount route. **1,310 reports across 1,237 solicitations**, `source="staff_report"` with the legdocs URL exposed. **Ariba-era only** by nature: pre-2019 council items have no dual-key `bid` row and don't join — that remainder is the deferred #124 surrogate spine's job. A staff report can appear both under its `council_item` and under its bridged solicitation — two views of one PDF, neither wrong.
- **No MFA on the account, by requirement.** An unattended login cannot answer a 2FA prompt and a CAPTCHA is a policy hard stop; `login` detects a challenge page and raises rather than hanging. Not part of `tb sync`.
- **A placeholder credential reads as unset (#184).** `scrapers/.env.example` ships in git with `ARIBA_USERNAME=your-ariba-supplier-username` / `ARIBA_PASSWORD=your-ariba-supplier-password` so the repo can stay public; on a checkout that copied it to `scrapers/.env` but never filled it in, `load_dotenv()` puts those placeholders into the real environment, and `os.environ.get(...)` reads them back as non-empty strings. `capture_attachments`'s own "creds unset" guard checks truthiness, which a placeholder satisfies — so before this, the guard never fired, `login()` proceeded, and the run died 30s later inside Playwright (`fill("your-ariba-supplier-username")` timing out) with nothing saying *why*. `config._real_env` treats a value equal to the known placeholder as `None` at load time, so `ARIBA_USERNAME`/`ARIBA_PASSWORD` are `None` on any checkout that never entered real credentials — the existing guard then fires immediately, before Playwright is even imported.

### Agency capture (`buyers.py`, `sources/trca_board.py`, `sources/zoo_board.py`, `sources/ep_board.py`, #135) — the fourth keyspace

Expand Down
33 changes: 33 additions & 0 deletions scrapers/tests/test_ariba_attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,39 @@ def test_cli_reindex_rebuilds_from_the_store(tmp_path, monkeypatch, capsys):
assert "Doc1234567891.zip" in out


def test_capture_attachments_fails_fast_with_no_credentials(conn, monkeypatch):
"""The guard fires BEFORE Playwright ever touches a browser — a genuinely unset credential
must not cost a 30s login timeout to discover (#184)."""
from toronto_bids import config
monkeypatch.setattr(config, "ARIBA_USERNAME", None)
monkeypatch.setattr(config, "ARIBA_PASSWORD", None)
try:
aa.capture_attachments(conn)
assert False, "expected a RuntimeError"
except RuntimeError as exc:
assert "unset" in str(exc)
assert "scrapers/.env" in str(exc)


def test_capture_attachments_fails_fast_on_a_placeholder_credential(conn, monkeypatch):
"""The exact #184 scenario end to end: scrapers/.env still holds the committed example's
placeholder values, so os.environ reads them back as non-empty strings. Runs the real
`config._real_env` over that placeholder (proving it resolves to None, not just asserting
it) and feeds the result to capture_attachments — the guard must react exactly as it does
to a truly-missing credential, with no Playwright import required to see it."""
from toronto_bids import config
monkeypatch.setenv("ARIBA_USERNAME", "your-ariba-supplier-username")
placeholder_resolved = config._real_env("ARIBA_USERNAME")
assert placeholder_resolved is None
monkeypatch.setattr(config, "ARIBA_USERNAME", placeholder_resolved)
monkeypatch.setattr(config, "ARIBA_PASSWORD", None)
try:
aa.capture_attachments(conn)
assert False, "expected a RuntimeError"
except RuntimeError as exc:
assert "unset" in str(exc)


def test_cli_capture_threads_virtual_display(tmp_path, monkeypatch):
# --virtual-display must reach capture_attachments (the flag a headless server needs).
from toronto_bids import config, cli
Expand Down
38 changes: 38 additions & 0 deletions scrapers/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""config.py's Ariba credential loading (#184).

`scrapers/.env.example` ships in git with placeholder values so the repo can stay public. On a
machine with no OTHER `.env` — a fresh checkout that copied the example but never filled it in —
`load_dotenv()` puts those placeholders into the real environment, and `os.environ.get(...)`
reads them back as non-empty strings. `capture_attachments`'s own "creds unset" guard checks
truthiness, which a placeholder satisfies, so it never fired: login() proceeded and died 30s
later inside Playwright with no hint why the fill failed. `_real_env` is the fix, tested in
isolation as a pure function — no need to touch the real environment or reload the module.
"""
from toronto_bids.config import _real_env


def test_the_placeholder_username_reads_as_unset(monkeypatch):
monkeypatch.setenv("ARIBA_USERNAME", "your-ariba-supplier-username")
assert _real_env("ARIBA_USERNAME") is None


def test_the_placeholder_password_reads_as_unset(monkeypatch):
monkeypatch.setenv("ARIBA_PASSWORD", "your-ariba-supplier-password")
assert _real_env("ARIBA_PASSWORD") is None


def test_a_real_credential_passes_through_unchanged(monkeypatch):
monkeypatch.setenv("ARIBA_USERNAME", "actual.supplier@example.com")
assert _real_env("ARIBA_USERNAME") == "actual.supplier@example.com"


def test_a_genuinely_unset_variable_stays_none(monkeypatch):
monkeypatch.delenv("ARIBA_USERNAME", raising=False)
assert _real_env("ARIBA_USERNAME") is None


def test_the_username_placeholder_does_not_blank_a_real_password(monkeypatch):
"""Each variable's placeholder is checked against ITS OWN known value — one placeholder
string must never accidentally match the other variable's real credential."""
monkeypatch.setenv("ARIBA_PASSWORD", "your-ariba-supplier-username")
assert _real_env("ARIBA_PASSWORD") == "your-ariba-supplier-username"
20 changes: 18 additions & 2 deletions scrapers/toronto_bids/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,24 @@
# (the repo is public — credentials never go in it). Respond is authorized by PMMD (#117), but
# a bid is never submitted.
ARIBA_LOGIN_URL = "https://service.ariba.com/Supplier.aw/109590048/aw?awh=r&awssk=login"
ARIBA_USERNAME = os.environ.get("ARIBA_USERNAME")
ARIBA_PASSWORD = os.environ.get("ARIBA_PASSWORD")
# scrapers/.env ships in git with placeholder values (so the repo can stay public) — on a
# machine with no OTHER .env, load_dotenv() above sets these placeholders into the real
# environment, and they read back as non-empty strings. `capture_attachments`'s own "creds
# unset" guard checks truthiness, which a placeholder satisfies, so it never fired: login()
# proceeded and died 30s later inside Playwright with no hint why the fill failed (#184). A
# placeholder reads as unset here instead — the one place both `capture_attachments` and any
# future caller check.
_ARIBA_PLACEHOLDER = {"ARIBA_USERNAME": "your-ariba-supplier-username",
"ARIBA_PASSWORD": "your-ariba-supplier-password"}


def _real_env(name: str) -> str | None:
value = os.environ.get(name)
return None if value == _ARIBA_PLACEHOLDER.get(name) else value


ARIBA_USERNAME = _real_env("ARIBA_USERNAME")
ARIBA_PASSWORD = _real_env("ARIBA_PASSWORD")

# TRCA meeting records (#135): current record on eSCRIBE, back-catalogue agenda packages
# on TRCA's Laserfiche. Both are TRCA's own hosting (open-data licence) — NOT the
Expand Down