Files
claude_mac/lib/seo-data/seo-data.test.sh
T
Bastien Chanot b00e8ef442 feat(seo-data): safe_fetch — resolve-then-pin, close DNS-rebinding + SSRF
By-principle hardening. H1's url-guard validates the NAME; urlopen then resolved
AND connected — two DNS lookups with a window a hostile authority uses to answer
PUBLIC to validation and PRIVATE (169.254.169.254 metadata, 127.0.0.1, the LAN)
to the connect. A name-level guard cannot see that rebind.

safe_fetch collapses the two lookups into one: resolve ONCE, validate every IP
(ipaddress, dual-stack v4+v6), refuse if ANY is non-public (the multi-A vector),
connect to the exact validated IP with Host+SNI+cert for the real host — no
second resolution to poison. Redirects re-validate each hop (urlopen followed
them blind). One seam: sitemap._fetch, which linkgraph/render_check/drift all
call, so every network verb inherits it.

The load-bearing property (confirmed by the security review): classification is
on the OS-resolved address (sockaddr[0]), never the URL text — so octal/hex/
decimal literals, IPv4-mapped IPv6, NAT64, 6to4 are all defeated structurally,
not by enumeration.

Better than the source idea (claude-seo url_safety.py, MIT): dual-stack (theirs
IPv4-only), no global monkeypatch so thread-safe by construction (theirs locks a
patched getaddrinfo), stdlib-only (no requests). Proven end-to-end before
writing: pinned connect keeps SNI+cert for the real host.

NOT covered, stated not silent: shell `curl` in the agent specs (separate
process, unpinnable here). Smaller surface; `curl --resolve` is a separate change.

REVIEW-SURFACED (fresh security-auditor, adversarial, VERDICT PASS) — two real
holes it found while attacking the diff, both fixed here:
- billion-laughs REOPENED in C1b: _refuse_dtd scanned only raw[:4096], so a
  >4KB leading comment pushed <!DOCTYPE past the window while ET parsed AND
  EXPANDED the entities. Proven (&lol2; → "lollollollollol"), now a full-doc
  case-insensitive scan. This is a genuine fix to already-merged C1b, not this
  feature — fixed here rather than filed, per root-cause discipline.
- 192.88.99.0/24 (6to4-relay anycast) passed is_global as public — added to an
  extra special-use deny list.

Verified: rebind-to-metadata refused BEFORE any connect (injected resolver),
multi-A public+private refused, classifier fuzzed dual-stack incl. CGNAT/6to4,
non-http scheme refused, both review fixes proven with no false positive; real
fetch still works (zenquality 86 loc, lavageangels 24) through the pinned path;
all 4 verbs work end-to-end via fetch.sh; seo-data 210 → 221 pass, 0 fail; full
suite green; shellcheck + py_compile clean.
2026-07-17 19:58:59 +02:00

563 lines
34 KiB
Bash

#!/usr/bin/env bash
# Deterministic tests for the seo-data engine (no network, no venv).
set -u
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
SD="$REPO/lib/seo-data"
PASS=0; FAIL=0
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
no() { echo " FAIL $1 — $2"; FAIL=$((FAIL+1)); }
# assert stdout of a command contains / omits a fixed string
has() { if printf '%s' "$2" | grep -qF -- "$3"; then ok "$1"; else no "$1" "missing: $3"; fi; }
hasnt(){ if printf '%s' "$2" | grep -qF -- "$3"; then no "$1" "forbidden: $3"; else ok "$1"; fi; }
echo "── tokenstore ──"
TMP="$(mktemp -d)"; STORE="$TMP/tokens.json"
python3 "$SD/tokenstore.py" set --file "$STORE" --label client-a \
--refresh-token RT_AAA --scopes https://www.googleapis.com/auth/webmasters.readonly \
--properties sc-domain:a.com,https://www.a.com/ >/dev/null
python3 "$SD/tokenstore.py" set --file "$STORE" --label client-b \
--refresh-token RT_BBB --scopes https://www.googleapis.com/auth/webmasters.readonly \
--properties sc-domain:b.com >/dev/null
LIST="$(python3 "$SD/tokenstore.py" list --file "$STORE")"
has "list shows client-a" "$LIST" '"client-a"'
has "list shows client-b" "$LIST" '"client-b"'
has "list shows a property" "$LIST" 'sc-domain:a.com'
hasnt "list redacts refresh tokens" "$LIST" 'RT_AAA'
PERM="$(stat -c '%a' "$STORE")"
[ "$PERM" = "600" ] && ok "store file is 0600" || no "store file 0600" "got $PERM"
DPERM="$(stat -c '%a' "$(dirname "$STORE")")"
[ "$DPERM" = "700" ] && ok "store dir is 0700" || no "store dir 0700" "got $DPERM"
rm -rf "$TMP"
echo "── crux (mock) ──"
CRUX_OK="$(SEO_DATA_MOCK_DIR="$REPO/lib/seo-data/fixtures" \
python3 "$SD/google_seo.py" crux --url https://ex.com --strategy mobile)"
has "crux status ok" "$CRUX_OK" '"status": "ok"'
has "crux lcp p75 mapped" "$CRUX_OK" '"lcp_p75_ms": 2100'
has "crux inp p75 mapped" "$CRUX_OK" '"inp_p75_ms": 180'
has "crux cls p75 mapped" "$CRUX_OK" '"cls_p75": 0.08'
CRUX_DEG="$(env -u CRUX_API_KEY -u SEO_DATA_MOCK_DIR \
python3 "$SD/google_seo.py" crux --url https://ex.com)"
has "crux degrades w/o key" "$CRUX_DEG" '"status": "degraded"'
has "crux degrade reason" "$CRUX_DEG" 'no_crux_key'
ORIG="$(python3 -c "import sys; sys.path.insert(0,'$SD'); import google_seo; print(google_seo._origin('https://example.com/blog/post'))")"
has "origin strips to host" "$ORIG" 'https://example.com'
hasnt "origin drops the path" "$ORIG" 'blog'
echo "── gsc (mock) ──"
MOCK="$REPO/lib/seo-data/fixtures"
TMP2="$(mktemp -d)"; S2="$TMP2/tokens.json"
python3 "$SD/tokenstore.py" set --file "$S2" --label client-a --refresh-token RT \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:ex.com >/dev/null
Q="$(SEO_DATA_MOCK_DIR="$MOCK" python3 "$SD/google_seo.py" queries \
--store "$S2" --account client-a --property sc-domain:ex.com --days 90)"
has "queries ok" "$Q" '"status": "ok"'
has "queries row key" "$Q" 'plombier paris'
has "queries position field" "$Q" '"position": 6.3'
I="$(SEO_DATA_MOCK_DIR="$MOCK" python3 "$SD/google_seo.py" inspect \
--store "$S2" --account client-a --property sc-domain:ex.com --url https://ex.com/x)"
has "inspect indexed true" "$I" '"indexed": true'
# rich_results rides the same URL-Inspection response (no extra call/quota)
has "rich verdict surfaced" "$I" '"verdict": "FAIL"'
has "rich type breadcrumbs" "$I" '"type": "Breadcrumbs"'
has "rich type faq" "$I" '"type": "FAQ"'
has "rich counts error severity" "$I" '"errors": 2'
has "rich counts warn severity" "$I" '"warnings": 1'
has "rich keeps issue message" "$I" "Missing field 'acceptedAnswer'"
# same issueMessage repeats across items — the rollup must collapse it to one
NMSG="$(printf '%s' "$I" | grep -cF "Missing field 'acceptedAnswer'")"
[ "$NMSG" = "1" ] && ok "rich dedupes issue messages" \
|| no "rich dedupes issue messages" "got $NMSG occurrences"
# Google OMITS richResultsResult when it detects none — absence is data, and
# must not KeyError nor vanish into a missing key
NR="$(SEO_DATA_MOCK_DIR="$SD/fixtures-norich" python3 "$SD/google_seo.py" inspect \
--store "$S2" --account client-a --property sc-domain:ex.com --url https://ex.com/x)"
has "no-rich → synthetic ABSENT" "$NR" '"verdict": "ABSENT"'
has "no-rich keeps index status" "$NR" '"indexed": true'
hasnt "no-rich emits no PARTIAL" "$NR" 'PARTIAL'
DEG="$(env -u SEO_DATA_MOCK_DIR python3 "$SD/google_seo.py" queries \
--store "$TMP2/none.json" --account nobody --property sc-domain:ex.com)"
has "gsc degrades w/o creds" "$DEG" '"status": "degraded"'
has "gsc degrade reason" "$DEG" 'no_credentials'
rm -rf "$TMP2"
echo "── cannibalisation ──"
# `keys` is additive: the single-dim consumer that reads `key` must not break
has "queries keeps key (compat)" "$Q" '"key": "plombier paris"'
has "queries adds keys list" "$Q" '"keys"'
CAN="$(SEO_DATA_MOCK_DIR="$SD/fixtures-cannibal" python3 "$SD/google_seo.py" cannibal \
--store "$S2" --account client-a --property sc-domain:ex.com)"
has "cannibal ok" "$CAN" '"status": "ok"'
# fixture: 3 pages on "urgence fuite", 2 on "plombier paris", 1 on "devis"
has "cannibal finds 2 conflicts" "$CAN" '"conflict_count": 2'
has "cannibal counts pages" "$CAN" '"pages": 3'
has "cannibal sums impressions" "$CAN" '"total_impressions": 2400'
hasnt "single-page query is not a conflict" "$CAN" 'devis plomberie'
# biggest conflict first, and inside it the strongest page first
CAN_FIRST="$(printf '%s' "$CAN" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["conflicts"][0]["query"], d["conflicts"][0]["urls"][0]["url"])')"
check_first() { [ "$1" = "$2" ] && ok "$3" || no "$3" "got[$1]"; }
check_first "$CAN_FIRST" "urgence fuite https://ex.com/urgence" "cannibal ranks by impact"
has "cannibal reports the cap" "$CAN" '"capped": false'
echo "── safe_fetch (DNS-rebinding / SSRF) ──"
# Inject a hostile resolver: the name is public, the address is internal. This
# is the rebinding vector a name-level guard cannot see — prove it is refused
# BEFORE any connection. Deterministic + offline via the injected resolver.
sfpy() { PYTHONPATH="$SD" python3 -c "$1" 2>&1; }
REBIND="$(sfpy '
import socket, safe_fetch as sf
def meta(h,p,**k): return [(socket.AF_INET,socket.SOCK_STREAM,6,"",("169.254.169.254",p))]
try: sf.safe_fetch("https://evil.example/", resolver=meta); print("CONNECTED")
except sf.UnsafeTarget as e: print("REFUSED", e)')"
has "rebind to metadata refused" "$REBIND" 'REFUSED'
has "refusal names the ip" "$REBIND" '169.254.169.254'
hasnt "never connected" "$REBIND" 'CONNECTED'
MIXED="$(sfpy '
import socket, safe_fetch as sf
def mix(h,p,**k): return [(socket.AF_INET,socket.SOCK_STREAM,6,"",("93.184.216.34",p)),
(socket.AF_INET,socket.SOCK_STREAM,6,"",("127.0.0.1",p))]
try: sf.safe_fetch("https://evil.example/", resolver=mix); print("CONNECTED")
except sf.UnsafeTarget as e: print("REFUSED")')"
has "multi-A public+private refused" "$MIXED" 'REFUSED'
# classification, dual-stack — is_global catches CGNAT the per-flags miss
CLS="$(sfpy '
import safe_fetch as sf
pub=[c for c in ["8.8.8.8","2606:2800:220:1:248:1893:25c8:1946"] if sf._ip_is_public(c)]
bad=[c for c in ["169.254.169.254","127.0.0.1","10.0.0.1","192.168.1.1","100.64.1.1","::1","fe80::1","0.0.0.0"] if sf._ip_is_public(c)]
print("PUB",len(pub),"BADPASS",len(bad))')"
has "public v4+v6 pass" "$CLS" 'PUB 2'
has "no internal ip passes" "$CLS" 'BADPASS 0'
# security review 2026-07-17: 6to4-relay anycast passes is_global — extra deny
SIXTOFOUR="$(sfpy 'import safe_fetch as sf; print("6TO4", sf._ip_is_public("192.88.99.1"))')"
has "6to4 relay anycast refused" "$SIXTOFOUR" '6TO4 False'
# scheme + stdlib
SCHEME="$(sfpy '
import safe_fetch as sf
try: sf.safe_fetch("file:///etc/passwd"); print("OK")
except sf.UnsafeTarget: print("REFUSED")')"
has "non-http scheme refused" "$SCHEME" 'REFUSED'
IMP="$(/bin/grep -E "^(import|from) " "$SD/safe_fetch.py" | /bin/grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse")"
[ "$IMP" = "0" ] && ok "safe_fetch is stdlib-only" || no "safe_fetch is stdlib-only" "$IMP non-stdlib imports"
hasnt "no requests dependency" "$(cat "$SD/safe_fetch.py")" 'import requests'
echo "── sitemap ──"
SM="$(SEO_DATA_MOCK_DIR="$MOCK" python3 "$SD/sitemap.py" --url https://ex.com/sitemap.xml)"
has "sitemap ok" "$SM" '"status": "ok"'
has "sitemap not an index" "$SM" '"index": false'
# fixture holds 8 <loc>: 1 empty, blog twice, ftp:// and a quoted one to drop
has "sitemap dedupes" "$SM" '"count": 4'
has "sitemap counts drops" "$SM" '"dropped": 2'
has "sitemap strips whitespace" "$SM" '"https://ex.com/spaced"'
hasnt "sitemap drops non-http" "$SM" 'ftp://'
hasnt "sitemap drops shell-meta" "$SM" 'bad"quote'
# namespace-agnostic: real sitemaps carry sitemaps.org xmlns (+ xhtml here)
has "sitemap reads namespaced" "$SM" '"https://ex.com/services"'
# REGRESSION: <image:loc> also ends with '}loc'. An endswith test counted image
# sitemap entries as pages — a real native site returned 27 for 24 <url>, and
# img/logo.png was about to be sampled and audited as a page.
hasnt "image:loc is not a page" "$SM" '/img/logo.png'
hasnt "image:loc jpeg not a page" "$SM" '/img/hero.jpeg'
has "image ns does not inflate count" "$SM" '"count": 4'
IDX="$(SEO_DATA_MOCK_DIR="$SD/fixtures-sitemap-index" python3 "$SD/sitemap.py" \
--url https://ex.com/sitemap.xml)"
has "sitemapindex detected" "$IDX" '"index": true'
has "sitemapindex fans out" "$IDX" '"children_read": 2'
has "sitemapindex no child fail" "$IDX" '"children_failed": 0'
has "sitemapindex yields urls" "$IDX" '"https://ex.com/child-a"'
# A sitemap NEVER has a DTD. Refused at the door: xml.etree does not expand
# external entities but IS billion-laughs-vulnerable, and the 20MB read ceiling
# bounds the input, not the expansion. Refusing beats depending on the parser,
# and keeps this module stdlib-only (no defusedxml, no venv).
DTD="$(SEO_DATA_MOCK_DIR="$SD/fixtures-sitemap-dtd" python3 "$SD/sitemap.py" \
--url https://ex.com/sitemap.xml)"
has "billion-laughs refused" "$DTD" '"status": "degraded"'
has "dtd reason is distinct" "$DTD" 'unsafe_xml_dtd'
hasnt "dtd never parsed" "$DTD" '"count"'
# security review 2026-07-17: a >4KB leading comment pushed <!DOCTYPE past the
# old raw[:4096] scan while ET still parsed+expanded it. Now the whole doc is
# scanned. Prove a padded DTD is refused and the entity never expands.
PADDED="$(python3 -c '
import sys; sys.path.insert(0,"'"$SD"'"); import sitemap as sm
bomb=("<?xml version=\"1.0\"?>\n<!-- "+("x"*5000)+" -->\n"
"<!DOCTYPE d [ <!ENTITY lol \"lol\"> ]>\n<urlset><url><loc>https://x/&lol;</loc></url></urlset>").encode()
try: sm._refuse_dtd(bomb); print("PARSED")
except sm.UnsafeXML: print("REFUSED")')"
has "padded DTD refused (full scan)" "$PADDED" 'REFUSED'
echo "── render_check (R2) ──"
SPA="$(SEO_DATA_MOCK_DIR="$SD/fixtures-spa" python3 "$SD/render_check.py" \
--url https://spa.example/)"
has "spa → client-rendered" "$SPA" '"verdict": "client-rendered"'
has "spa has no h1 in html" "$SPA" '"h1_in_html": 0'
has "spa warns about false negs" "$SPA" 'false'
# the shell carries a fat window.__INITIAL_STATE__ script: script text is NOT
# page text, or a 200KB React bundle would read as a rich page
has "script text is not content" "$SPA" '"body_text_chars": 7'
SSR="$(SEO_DATA_MOCK_DIR="$SD/fixtures-ssr" python3 "$SD/render_check.py" \
--url https://ssr.example/)"
has "ssr → server-rendered" "$SSR" '"verdict": "server-rendered"'
has "ssr counts jsonld" "$SSR" '"jsonld_in_html": 1'
has "ssr sees meta description" "$SSR" '"meta_description_in_html": true'
hasnt "ssr emits no warning" "$SSR" 'warning'
echo "── linkgraph ──"
LG="$(SEO_DATA_MOCK_DIR="$SD/fixtures-linkgraph" python3 "$SD/linkgraph.py" \
--url https://ex.com/sitemap.xml)"
has "linkgraph ok" "$LG" '"status": "ok"'
has "linkgraph crawls all" "$LG" '"pages_crawled": 7'
# THE test: a planted page nobody links to must be found. Two live sites both
# returned zero orphans; without this, "always returns []" looks identical.
has "finds the planted orphan" "$LG" '"https://ex.com/orphan"'
has "orphan is also unreachable" "$LG" '"unreachable"'
has "depth chain measured" "$LG" '"max_depth": 4'
has "flags >3 clicks" "$LG" '"https://ex.com/deepest"'
# home links: /a and /b only. anchor, .css?v=, mailto:, tel:, external, .png
# are not page links — 9 total across the 7 pages.
has "filters non-page links" "$LG" '"total_internal_links": 9'
hasnt "no external host" "$LG" 'other.com'
hasnt "no asset link" "$LG" 'main.css'
hasnt "no image link" "$LG" 'logo.png'
# /b/ in the markup vs /b in the sitemap must be ONE node, not a phantom orphan
hasnt "trailing slash unified" "$LG" '"https://ex.com/b/"'
# An orphan from a partial crawl is a false orphan: withhold, do not truncate.
CAP="$(SEO_DATA_MOCK_DIR="$SD/fixtures-linkgraph" python3 "$SD/linkgraph.py" \
--url https://ex.com/sitemap.xml --max 3)"
has "cap is reported" "$CAP" '"capped": true'
has "capped withholds orphans" "$CAP" '"orphans_withheld": true'
hasnt "capped emits no orphans" "$CAP" '"orphans":'
echo "── score (I7) ──"
sc() { printf '%s' "$1" | python3 "$SD/score.py" --findings -; }
# technical: haute(-8) + moyenne(-3) = 100-11 = 89 → 17.8
B='{"depth":"FULL","profile":"local","axes":{"technical":{"findings":[{"severity":"haute"},{"severity":"moyenne"}]},"seo-local":{"findings":[]},"off-page":{"findings":[]},"social":{"findings":[]},"competitive":{"findings":[]},"legal":{"findings":[]},"on-page":{"findings":[]}}}'
R="$(sc "$B")"
has "harden scale, /5 into /20" "$R" '"score_20": 17.8'
has "no findings = 20" "$R" '"score_20": 20.0'
has "nothing renormalised" "$R" '"weights_renormalised": false'
# THE point of I7: same findings in, same score out
A1="$(sc "$B" | python3 -c 'import sys,json;print(json.load(sys.stdin)["global_20"])')"
A2="$(sc "$B" | python3 -c 'import sys,json;print(json.load(sys.stdin)["global_20"])')"
[ "$A1" = "$A2" ] && ok "score is reproducible" || no "score is reproducible" "$A1 vs $A2"
# N/A is not a zero, and R2 mandated renormalising by hand — now computed
NA='{"depth":"FULL","profile":"local","axes":{"technical":{"findings":[]},"on-page":{"status":"na"},"seo-local":{"findings":[]},"off-page":{"status":"na"},"social":{"findings":[]},"competitive":{"findings":[]},"legal":{"findings":[]}}}'
RN="$(sc "$NA")"
has "na axes listed" "$RN" '"on-page"'
has "renormalisation flagged" "$RN" '"weights_renormalised": true'
# all axes 20 → global must stay 20: N/A must not drag the mean down
has "na is not a zero" "$RN" '"global_20": 20.0'
# prevalence shifts severity ONE step, both ways
WIDE='{"depth":"LOCAL","profile":"local","axes":{"technical":{"findings":[{"severity":"moyenne","affected":10,"sampled":12}]},"on-page":{"findings":[]},"seo-local":{"findings":[]},"legal":{"findings":[]}}}'
ONE='{"depth":"LOCAL","profile":"local","axes":{"technical":{"findings":[{"severity":"moyenne","affected":1,"sampled":12}]},"on-page":{"findings":[]},"seo-local":{"findings":[]},"legal":{"findings":[]}}}'
has "widespread escalates (-8)" "$(sc "$WIDE")" '"score_20": 18.4'
has "isolated de-escalates (-1)" "$(sc "$ONE")" '"score_20": 19.8'
# malformed input is an error, never a silently wrong number
has "unknown severity rejected" "$(sc '{"depth":"FULL","profile":"local","axes":{"technical":{"findings":[{"severity":"bogus"}]}}}')" '"status": "error"'
has "unknown profile rejected" "$(sc '{"depth":"FULL","profile":"martian","axes":{}}')" '"status": "error"'
has "garbage json is an error" "$(sc 'not json')" '"status": "error"'
echo "── drift (H2) ──"
DH="$(mktemp -d)"
D1="$(HOME="$DH" SEO_DATA_MOCK_DIR="$SD/fixtures-drift-v1" python3 "$SD/drift.py" \
--url https://ex.com/sitemap.xml)"
has "first run is a baseline" "$D1" '"baseline": true'
has "baseline captures pages" "$D1" '"pages": 3'
hasnt "baseline diffs nothing" "$D1" '"regressions"'
# v2: canonical lost on /a, h1+jsonld lost on /, title reworded, /gone removed,
# /neuve added. Losses are regressions; a reworded title is not.
D2="$(HOME="$DH" SEO_DATA_MOCK_DIR="$SD/fixtures-drift-v2" python3 "$SD/drift.py" \
--url https://ex.com/sitemap.xml)"
has "second run diffs" "$D2" '"baseline": false'
has "detects removed url" "$D2" '"https://ex.com/gone"'
has "detects added url" "$D2" '"https://ex.com/neuve"'
has "lost canonical = regression" "$D2" '"canonical"'
has "lost h1 = regression" "$D2" '"h1_count"'
has "lost jsonld = regression" "$D2" '"jsonld_types"'
# the classification IS the feature: losing a signal != changing one
NREG="$(printf '%s' "$D2" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)["regressions"]))')"
NCHG="$(printf '%s' "$D2" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)["changes"]))')"
[ "$NREG" = "3" ] && ok "3 losses classed as regressions" \
|| no "3 losses classed as regressions" "got $NREG"
[ "$NCHG" = "1" ] && ok "reworded title is a change, not a regression" \
|| no "reworded title is a change, not a regression" "got $NCHG"
rm -rf "$DH"
echo "── schema_gen ──"
SG() { python3 "$SD/schema_gen.py" "$@"; }
RES="$(SG reservation --provider "Chez X" --start "2026-08-01T19:00")"
has "reservation ok" "$RES" '"status": "ok"'
has "reservation type surfaced" "$RES" '"type": "FoodEstablishmentReservation"'
has "jsonld has @context" "$RES" '"@context": "https://schema.org"'
has "reservation keeps provider" "$RES" 'Chez X'
has "reservation keeps start" "$RES" '2026-08-01T19:00'
PROF="$(SG profile --name "Jane Doe" --url https://ex.com/about)"
has "profile ok" "$PROF" '"status": "ok"'
has "profile type surfaced" "$PROF" '"type": "ProfilePage"'
ORD="$(SG order --merchant "Acme" --order-url https://ex.com/order)"
has "order ok" "$ORD" '"status": "ok"'
has "order type surfaced" "$ORD" '"type": "OrderAction"'
DISC="$(SG discussion --headline "Q" --author "Jo" --url https://ex.com/t/1 \
--date 2026-05-01T00:00:00Z)"
has "discussion ok" "$DISC" '"status": "ok"'
has "discussion type surfaced" "$DISC" '"type": "DiscussionForumPosting"'
# argparse required=True catches an OMITTED flag → bad usage, exit 2
BADRES="$(SG reservation --start 2026-08-01T19:00 2>/dev/null)"; BADRC=$?
hasnt "missing --provider is not ok" "$BADRES" '"status": "ok"'
[ "$BADRC" = "2" ] && ok "missing --provider exit 2" \
|| no "missing --provider exit 2" "got $BADRC"
# a required field argparse ALLOWS through (flag given, value empty) must
# still fail open — degraded, not a crash, exit 0
EMPTYRES="$(SG reservation --provider "" --start 2026-08-01T19:00)"; EMPTYRC=$?
hasnt "empty --provider is not ok" "$EMPTYRES" '"status": "ok"'
has "empty --provider degrades" "$EMPTYRES" '"status": "degraded"'
[ "$EMPTYRC" = "0" ] && ok "empty --provider exit 0" \
|| no "empty --provider exit 0" "got $EMPTYRC"
# --script-tag must work AFTER the type, matching `fetch.sh schema_gen
# <type> [flags]` — the shape the dispatcher actually calls it with. The
# envelope is JSON, so the `script` field's own quotes are backslash-escaped
# in the raw stdout — decode it to check the LITERAL wrapper string.
SCRIPT="$(SG profile --name "Jane Doe" --url https://ex.com/about --script-tag)"
SCRIPT_TAG="$(printf '%s' "$SCRIPT" | \
python3 -c 'import sys,json; print(json.load(sys.stdin)["script"])')"
has "script-tag wraps output" "$SCRIPT_TAG" '<script type="application/ld+json">'
# an omitted optional field must never surface as a JSON null
hasnt "no null ever emitted" "$RES" 'null'
# stdlib ONLY — no requests/httpx/bs4/any third-party import
IMPORTS="$(grep -E '^(import|from) ' "$SD/schema_gen.py")"
if printf '%s' "$IMPORTS" | grep -qiE 'requests|httpx|bs4'; then
no "schema_gen stdlib only" "third-party import found: $IMPORTS"
else
ok "schema_gen stdlib only"
fi
# dispatch wiring: --store precedes the type (fetch.sh's own convention),
# --script-tag comes after it (the caller's convention) — both must work
# through the real fetch.sh entrypoint, not just the bare script
FSG="$(SEO_DATA_ENV_FILE=/dev/null SEO_DATA_STORE=/nonexistent bash "$SD/fetch.sh" \
schema_gen reservation --provider "Chez X" --start 2026-08-01T19:00 --script-tag)"
has "fetch dispatches schema_gen" "$FSG" '"status": "ok"'
FSG_TAG="$(printf '%s' "$FSG" | \
python3 -c 'import sys,json; print(json.load(sys.stdin)["script"])')"
has "fetch schema_gen script-tag" "$FSG_TAG" '<script type="application/ld+json">'
echo "── content_quality ──"
CQ() { python3 "$SD/content_quality.py" "$@"; }
# feed the phrase list's OWN entries so the match is exact, not paraphrased —
# a detector proven only on the maintainer's paraphrase proves nothing
FILLER_TXT="In today's fast-paced world, it's important to note that this \
article will delve into the ever-evolving landscape of technology. Let's \
dive in and navigate the complexities together, leveraging the power of \
innovation to unlock the potential of your business. Ultimately, this \
cutting-edge, state-of-the-art approach is a testament to progress. \
Moreover, furthermore, in conclusion, transform your outcomes today."
CLEAN_TXT="The 2024 ADEME report found French households spent 2,137 EUR \
on heating, up 12% from 2021."
FILLER_OUT="$(printf '%s' "$FILLER_TXT" | CQ)"
CLEAN_OUT="$(printf '%s' "$CLEAN_TXT" | CQ)"
has "filler text is ok" "$FILLER_OUT" '"status": "ok"'
has "clean text is ok" "$CLEAN_OUT" '"status": "ok"'
# flags is a JSON array — extract it in isolation so the check can't be
# fooled by the always-present "matches": {"filler": [...]} key sharing
# the same quoted word
FILLER_FLAGS="$(printf '%s' "$FILLER_OUT" | \
python3 -c 'import sys,json; print(",".join(json.load(sys.stdin)["flags"]))')"
CLEAN_FLAGS="$(printf '%s' "$CLEAN_OUT" | \
python3 -c 'import sys,json; print(",".join(json.load(sys.stdin)["flags"]))')"
case "$FILLER_FLAGS" in
*filler*|*ai-patterns*) ok "filler-heavy text is flagged" ;;
*) no "filler-heavy text is flagged" "flags: $FILLER_FLAGS" ;;
esac
hasnt "clean text is not flagged filler" "$CLEAN_FLAGS" 'filler'
hasnt "clean text is not flagged ai-patterns" "$CLEAN_FLAGS" 'ai-patterns'
# proves BOTH directions: an always-flag or a never-flag detector is useless
FILLER_Q="$(printf '%s' "$FILLER_OUT" | \
python3 -c 'import sys,json; print(json.load(sys.stdin)["overall_quality"])')"
CLEAN_Q="$(printf '%s' "$CLEAN_OUT" | \
python3 -c 'import sys,json; print(json.load(sys.stdin)["overall_quality"])')"
[ "$FILLER_Q" -lt 50 ] && ok "filler-heavy text scores LOW overall_quality" \
|| no "filler-heavy text scores LOW overall_quality" "got $FILLER_Q"
[ "$CLEAN_Q" -gt "$FILLER_Q" ] && ok "clean dense text scores higher" \
|| no "clean dense text scores higher" "$CLEAN_Q vs $FILLER_Q"
# empty / whitespace-only input never crashes and never claims a result
EMPTY_OUT="$(printf '' | CQ)"
has "empty input degrades" "$EMPTY_OUT" '"status": "degraded"'
has "empty input reason" "$EMPTY_OUT" 'empty_input'
WS_OUT="$(printf ' \n\t ' | CQ)"
has "whitespace-only degrades" "$WS_OUT" '"status": "degraded"'
# --file path works, no fixture committed — mktemp + rm
CQTMP="$(mktemp)"; printf '%s' "$CLEAN_TXT" > "$CQTMP"
FILE_OUT="$(CQ --file "$CQTMP")"
has "file input is ok" "$FILE_OUT" '"status": "ok"'
rm -f "$CQTMP"
# a missing --file degrades, never a traceback
MISSING_OUT="$(CQ --file /nonexistent/path/content-quality-test.txt)"
has "missing --file degrades" "$MISSING_OUT" '"status": "degraded"'
# stdlib ONLY — asserted, not assumed
CQ_IMPORTS="$(grep -E '^(import|from) ' "$SD/content_quality.py")"
if printf '%s' "$CQ_IMPORTS" | grep -qivE '^(import argparse, json, re, sys|from collections import counter|from typing import iterable)$'; then
no "content_quality stdlib only" "unexpected import: $CQ_IMPORTS"
else
ok "content_quality stdlib only"
fi
# ADVISORY HONESTY (LRN-131/133): a heuristic signal, never a verdict
hasnt "never claims ai-written" "$FILLER_OUT" 'ai-written'
hasnt "never claims is AI verdict" "$FILLER_OUT" 'is AI'
# dispatch wiring: --store precedes the verb (fetch.sh's own convention);
# both stdin AND --file must work through the real entrypoint
FCQ_STDIN="$(printf '%s' "$CLEAN_TXT" | \
SEO_DATA_ENV_FILE=/dev/null SEO_DATA_STORE=/nonexistent bash "$SD/fetch.sh" content_quality)"
has "fetch dispatches content_quality (stdin)" "$FCQ_STDIN" '"status": "ok"'
CQTMP2="$(mktemp)"; printf '%s' "$CLEAN_TXT" > "$CQTMP2"
FCQ_FILE="$(SEO_DATA_ENV_FILE=/dev/null SEO_DATA_STORE=/nonexistent bash "$SD/fetch.sh" \
content_quality --file "$CQTMP2")"
has "fetch dispatches content_quality (--file)" "$FCQ_FILE" '"status": "ok"'
rm -f "$CQTMP2"
echo "── fetch.sh ──"
FETCH="$SD/fetch.sh"
# SEO_DATA_ENV_FILE=/dev/null: tests must NEVER source the real ~/.claude/.env —
# on a machine with a live CRUX_API_KEY the degrade tests would hit the network.
NOENV=/dev/null
ACC="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE=/nonexistent/tokens.json bash "$FETCH" accounts)"
has "accounts empty is ok json" "$ACC" '"accounts": []'
CR="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_MOCK_DIR="$MOCK" bash "$FETCH" crux --url https://ex.com)"
has "fetch crux ok" "$CR" '"status": "ok"'
SEO_DATA_ENV_FILE=$NOENV bash "$FETCH" bogus-subcmd >/dev/null 2>&1; RC=$?
[ "$RC" = "2" ] && ok "bad subcmd exit 2" || no "bad subcmd exit 2" "got $RC"
DG="$(SEO_DATA_ENV_FILE=$NOENV env -u SEO_DATA_MOCK_DIR -u CRUX_API_KEY bash "$FETCH" crux --url https://ex.com)"; RC=$?
has "degrade json" "$DG" '"status": "degraded"'
[ "$RC" = "0" ] && ok "degrade exit 0" || no "degrade exit 0" "got $RC"
# redaction through the real fetch.sh dispatch layer
TMP4="$(mktemp -d)"; RSTORE="$TMP4/rt.json"
python3 "$SD/tokenstore.py" set --file "$RSTORE" --label leaky --refresh-token RT_SECRET_XYZ \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:z.com >/dev/null
ACCJSON="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$RSTORE" bash "$FETCH" accounts)"
has "accounts lists label" "$ACCJSON" 'leaky'
hasnt "accounts hides token" "$ACCJSON" 'RT_SECRET_XYZ'
rm -rf "$TMP4"
# corrupted store must degrade with JSON + exit 0 (Fix 1)
TMP5="$(mktemp -d)"; CSTORE="$TMP5/corrupt.json"; printf 'not json {{' > "$CSTORE"
CJ="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$CSTORE" bash "$FETCH" accounts)"; CRC=$?
has "corrupt store degrades" "$CJ" '"status"'
[ "$CRC" = "0" ] && ok "corrupt store exit 0" || no "corrupt store exit 0" "got $CRC"
rm -rf "$TMP5"
# bad usage (known subcmd, missing flag) must still emit JSON + exit 2 (Fix 2)
BU="$(SEO_DATA_ENV_FILE=$NOENV bash "$FETCH" crux)"; BURC=$?
has "bad usage emits json" "$BU" '"status"'
[ "$BURC" = "2" ] && ok "bad usage exit 2" || no "bad usage exit 2" "got $BURC"
echo "── connect (persist, offline) ──"
TMP3="$(mktemp -d)"; S3="$TMP3/tokens.json"
python3 -c "import sys; sys.path.insert(0,'$SD'); import connect; \
connect.persist('$S3','client-x','RT_X',['https://www.googleapis.com/auth/webmasters.readonly'],['sc-domain:x.com'])"
L3="$(python3 "$SD/tokenstore.py" list --file "$S3")"
has "connect.persist wrote label" "$L3" '"client-x"'
has "connect.persist wrote prop" "$L3" 'sc-domain:x.com'
hasnt "connect.persist redacts" "$L3" 'RT_X'
rm -rf "$TMP3"
echo "── forget (remove/clear) ──"
TMP6="$(mktemp -d)"; S6="$TMP6/tokens.json"
python3 "$SD/tokenstore.py" set --file "$S6" --label keep --refresh-token RT_KEEP \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:k.com >/dev/null
python3 "$SD/tokenstore.py" set --file "$S6" --label drop --refresh-token RT_DROP \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:d.com >/dev/null
RM="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label drop)"
has "remove reports ok" "$RM" '"status": "ok"'
has "remove reports removed" "$RM" '"removed": true'
hasnt "remove prints no token" "$RM" 'RT_DROP'
L6="$(python3 "$SD/tokenstore.py" list --file "$S6")"
has "remove keeps others" "$L6" '"keep"'
hasnt "removed label gone" "$L6" '"drop"'
RM2="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label ghost)"
has "remove missing = false" "$RM2" '"removed": false'
CL="$(python3 "$SD/tokenstore.py" clear --file "$S6")"
has "clear reports ok" "$CL" '"status": "ok"'
has "clear reports count" "$CL" '"cleared": 1'
L7="$(python3 "$SD/tokenstore.py" list --file "$S6")"
has "clear empties store" "$L7" '"accounts": []'
PERM6="$(stat -c '%a' "$S6")"
[ "$PERM6" = "600" ] && ok "store stays 0600 after clear" || no "store 0600 after clear" "got $PERM6"
# via the real fetch.sh dispatch layer
python3 "$SD/tokenstore.py" set --file "$S6" --label back --refresh-token RT_BACK \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:b.com >/dev/null
FG="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label back)"; FRC=$?
has "fetch forget removes" "$FG" '"removed": true'
[ "$FRC" = "0" ] && ok "fetch forget exit 0" || no "fetch forget exit 0" "got $FRC"
FB="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget)"; FRC2=$?
has "forget bad usage json" "$FB" '"status"'
[ "$FRC2" = "2" ] && ok "forget bad usage exit 2" || no "forget bad usage exit 2" "got $FRC2"
FA="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --all)"
has "fetch forget --all ok" "$FA" '"status": "ok"'
FI="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label 'x;touch /tmp/pwn')"; FIRC=$?
has "forget unsafe label json" "$FI" '"status":"error"'
[ "$FIRC" = "2" ] && ok "forget unsafe label exit 2" || no "forget unsafe label exit 2" "got $FIRC"
# embedded-newline label must NOT pass the per-line-grep pitfall
FN="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label "$(printf 'ok\nrm -rf x')")"; FNRC=$?
has "forget newline label json" "$FN" '"status":"error"'
[ "$FNRC" = "2" ] && ok "forget newline label exit 2" || no "forget newline label exit 2" "got $FNRC"
rm -rf "$TMP6"
echo "── connect.sh (offline negative) ──"
CN="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
bash "$SD/connect.sh" --label t 2>&1)"; CNRC=$?
[ "$CNRC" != "0" ] && ok "connect.sh no-creds nonzero" || no "connect.sh no-creds nonzero" "got 0"
has "connect.sh creds gate msg" "$CN" 'GOOGLE_OAUTH_CLIENT_ID'
CU="$(SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label 'x;y' 2>&1)"; CURC=$?
[ "$CURC" = "2" ] && ok "connect.sh unsafe label exit 2" || no "connect.sh unsafe label exit 2" "got $CURC"
has "connect.sh label guard msg" "$CU" 'unsafe label'
# parser-differential bypasses must be rejected too (=-joined, abbreviated)
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label='x;y' >/dev/null 2>&1; CJRC=$?
[ "$CJRC" = "2" ] && ok "connect.sh =-joined rejected" || no "connect.sh =-joined rejected" "got $CJRC"
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --labe 'x;y' >/dev/null 2>&1; CBRC=$?
[ "$CBRC" = "2" ] && ok "connect.sh abbrev rejected" || no "connect.sh abbrev rejected" "got $CBRC"
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label "$(printf 'ok\nrm -rf x')" >/dev/null 2>&1; CWRC=$?
[ "$CWRC" = "2" ] && ok "connect.sh newline rejected" || no "connect.sh newline rejected" "got $CWRC"
# a VALID label must still reach the creds gate (guard is not over-tight)
CV="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
bash "$SD/connect.sh" --label ok-1.2_3 2>&1)"; CVRC=$?
[ "$CVRC" = "1" ] && ok "connect.sh valid label reaches gate" || no "connect.sh valid label reaches gate" "got $CVRC"
has "connect.sh valid gate msg" "$CV" 'GOOGLE_OAUTH_CLIENT_ID'
echo "── wiring locks ──"
tf() { if grep -qF -- "$3" "$2" 2>/dev/null; then ok "$1"; else no "$1" "missing: $3"; fi; }
tf "env.example client id" "$REPO/.env.example" "GOOGLE_OAUTH_CLIENT_ID="
tf "env.example crux key" "$REPO/.env.example" "CRUX_API_KEY="
tf "makefile seo-connect" "$REPO/Makefile" "seo-connect:"
tf "makefile delegates wrapper" "$REPO/Makefile" "lib/seo-data/connect.sh"
tf "connect.sh sources vault" "$SD/connect.sh" ".claude/.env"
tf "makefile discovers test" "$REPO/Makefile" "lib/seo-data/*.test.sh"
tf "install prompts connect" "$REPO/install.sh" "make seo-connect"
tf "doctor checks seo-data" "$REPO/doctor.sh" "seo-data"
tf "gitleaks allowlist store" "$REPO/.gitleaks.toml" "seo-data/tokens"
tf "gitignore venv" "$REPO/.gitignore" ".venv-seo-data"
echo "── integration locks ──"
tf "skill step0 account select" "$REPO/skills/seo/SKILL.md" "COMPTE GOOGLE"
tf "analyzer calls fetch crux" "$REPO/agents/seo-analyzer.md" "fetch.sh crux"
tf "analyzer calls fetch queries" "$REPO/agents/seo-analyzer.md" "fetch.sh queries"
tf "analyzer gsc subsection" "$REPO/agents/seo-analyzer.md" "Performance GSC"
tf "catalog gsc oauth entry" "$REPO/agents/resources/automation-catalog.md" "make seo-connect"
tf "geo-analyzer wires schema_gen" "$REPO/agents/geo-analyzer.md" "fetch.sh schema_gen"
tf "geo-analyzer wires content_quality" "$REPO/agents/geo-analyzer.md" "fetch.sh content_quality"
echo "── account-mgmt locks ──"
tf "skill routes account verbs" "$REPO/skills/seo/SKILL.md" "forget --all"
tf "skill connect wrapper path" "$REPO/skills/seo/SKILL.md" "lib/seo-data/connect.sh"
tf "skill revocation notice" "$REPO/skills/seo/SKILL.md" "myaccount.google.com/permissions"
tf "skill label charset rule" "$REPO/skills/seo/SKILL.md" "A-Za-z0-9._-"
echo "── readme lock ──"
tf "readme documents fetch.sh" "$REPO/lib/seo-data/README.md" "fetch.sh"
tf "readme documents seo-connect" "$REPO/lib/seo-data/README.md" "make seo-connect"
tf "readme documents forget" "$REPO/lib/seo-data/README.md" "forget --all"
tf "readme revocation note" "$REPO/lib/seo-data/README.md" "myaccount.google.com/permissions"
tf "readme documents schema_gen" "$REPO/lib/seo-data/README.md" "schema_gen"
tf "readme documents content_quality" "$REPO/lib/seo-data/README.md" "content_quality"
tf "readme states advisory caveat" "$REPO/lib/seo-data/README.md" "ADVISORY, NOT A VERDICT"
echo ""
echo "seo-data engine: $PASS pass, $FAIL fail"
[ "$FAIL" -eq 0 ]