Spike: Facebook Page access probe — key valid and non-expiring, reaches no Page (R-914, R-915 opened)
gates / gates (push) Successful in 4m34s
gates / gates (push) Successful in 4m34s
scripts/facebook/fb_probe.py (stdlib, read + write-test, no real-post command) with tests; read run twice: SYSTEM_USER, expires_at 0, /me/accounts empty, so D/E did not run and nothing was posted. Findings, redacted evidence, CONTEXT decision home, STATUS item, scripts CHANGELOG, REPORT-facebook-page-api.md.
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
## facebook — the Page access probe `scripts/facebook/fb_probe.py` (2026-10-08, spike)
|
||||
|
||||
- Stdlib probe for the Meta Graph API (v26.0): `read` (debug_token, /me, /me/accounts, Page fields, feed, insights) and
|
||||
`write-test` (one scheduled text post + one scheduled photo, read back as UTF-8 hex, deleted, removal proven by a
|
||||
failed GET). No „post for real" command. Token only from `~/.config/credentials` via R-453's `unwrap`, sent as a
|
||||
Bearer header; every saved response passes `redact()`. `test_fb_probe.py` (10 tests; red-proven against a copy with
|
||||
the `access_token` drop and the `EAA` assert removed — 2 failures). Findings: `documentation/audits/SPIKE-facebook-page-api-2026-10-08.md`.
|
||||
|
||||
## gates — the decoy suite runs in a linked git worktree (2026-10-08, fixed without a row)
|
||||
|
||||
- `test_gate_decoys.py` took its lock at `ROOT/.git/decoy-suite.lock`; in a `git worktree add` copy `.git` is a FILE, so
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""fb_probe.py — the Facebook Page access probe (spike 2026-10-08). Stdlib only.
|
||||
|
||||
Usage: python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] read
|
||||
python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] write-test
|
||||
|
||||
`read` Scenarios A-C: the system-user token debugs itself, reaches the Felhom.eu Page, derives
|
||||
the Page token (memory only), reads the Page, its feed and a few insights metrics.
|
||||
`write-test` Scenarios D-E: ONE scheduled text post and ONE scheduled photo, a week ahead, each read
|
||||
back (UTF-8 hex compare) and DELETED; removal is proven by a failed GET, never by
|
||||
DELETE's own success. Refuses unless A and B pass and the Page grants CREATE_CONTENT.
|
||||
|
||||
There is deliberately NO "post for real" sub-command: real posting belongs to the skill written
|
||||
from this spike's findings (audits/SPIKE-facebook-page-api-2026-10-08.md).
|
||||
|
||||
SECRETS. The token comes ONLY from ~/.config/credentials (key FACEBOOK_API); no flag takes one.
|
||||
It travels as `Authorization: Bearer`, never in a logged URL (the one query-string carrier,
|
||||
debug_token's input_token, is never printed: -v prints the path without its query). Every saved
|
||||
response passes redact(): every `access_token` key is dropped and any string carrying a held token
|
||||
or an `EAA…` token shape is replaced. The Page token never touches disk.
|
||||
|
||||
Exit codes: 0 ok · 2 key refused · 3 Scenario A failed · 4 Scenario B failed · 5 write gate refused
|
||||
· 6 a write was refused (stop, finding) · 7 a test object could not be proven removed
|
||||
· 8 a test object read back PUBLISHED (deleted at once — read the report first)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
from read_credential import CredentialError, unwrap # noqa: E402 (R-453: the one quote-stripper)
|
||||
|
||||
GRAPH = "https://graph.facebook.com"
|
||||
APP_ID = "2273465403490709"
|
||||
PAGE_NAME = "Felhom.eu"
|
||||
KEY = "FACEBOOK_API"
|
||||
DEFAULT_EVIDENCE = os.path.join(ROOT, "documentation", "audits", "facebook-page-api-2026-10-08")
|
||||
LOGO = os.path.join(ROOT, "website", "assets", "logo.png")
|
||||
WEEK_S = 7 * 24 * 3600
|
||||
# "Felhom teszt – árvíztűrő tükörfúrógép. Ez a bejegyzés törlődik." — built from escapes, never typed
|
||||
# through a shell (brief §9.8).
|
||||
TEST_TEXT = ("Felhom teszt – árvíztűrő tükörfúrógép. "
|
||||
"Ez a bejegyzés törlődik.")
|
||||
HEADERS_KEPT = ("facebook-api-version", "x-business-use-case-usage", "x-app-usage", "x-page-usage",
|
||||
"x-fb-trace-id", "x-fb-rev")
|
||||
TOKEN_SHAPE = re.compile(r"EAA[A-Za-z0-9]{10,}")
|
||||
|
||||
VERBOSE = False
|
||||
SECRETS = [] # every token value held this run; redact() scrubs each
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def vlog(msg):
|
||||
if VERBOSE:
|
||||
print(" " + msg, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the key (R-453)
|
||||
|
||||
def load_key(path, key=KEY):
|
||||
"""Return the value of `key`. Accepts an optional `export ` prefix; ONE matching quote pair is
|
||||
stripped by read_credential.unwrap. Then asserts: no quote, no whitespace, starts with EAA."""
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
s = line.strip()
|
||||
if s.startswith("export "):
|
||||
s = s[len("export "):].lstrip()
|
||||
if s.startswith(key + "="):
|
||||
value = unwrap(s[len(key) + 1:].strip())
|
||||
if any(q in value for q in ("'", '"')):
|
||||
raise CredentialError("value still contains a quote character")
|
||||
if any(c.isspace() for c in value):
|
||||
raise CredentialError("value contains whitespace")
|
||||
if not value.startswith("EAA"):
|
||||
raise CredentialError("value does not start with EAA (starts %r)" % value[:3])
|
||||
return value
|
||||
raise CredentialError("key %r not present in %s" % (key, path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- redaction
|
||||
|
||||
def redact(obj, secrets=None):
|
||||
"""Deep copy of `obj` with every access_token key removed and every token-bearing string replaced."""
|
||||
secrets = SECRETS if secrets is None else secrets
|
||||
if isinstance(obj, dict):
|
||||
return {k: redact(v, secrets) for k, v in obj.items() if k != "access_token"}
|
||||
if isinstance(obj, list):
|
||||
return [redact(v, secrets) for v in obj]
|
||||
if isinstance(obj, str):
|
||||
if any(s and s in obj for s in secrets) or TOKEN_SHAPE.search(obj):
|
||||
return "[REDACTED]"
|
||||
return obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- HTTP
|
||||
|
||||
class Call:
|
||||
def __init__(self, step, method, path, status, body, headers, err):
|
||||
self.step, self.method, self.path = step, method, path
|
||||
self.status, self.body, self.headers, self.err = status, body, headers, err
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return self.err is None
|
||||
|
||||
|
||||
class Graph:
|
||||
def __init__(self, version, evidence):
|
||||
self.version, self.evidence = version, evidence
|
||||
self.headers_seen = {}
|
||||
os.makedirs(evidence, exist_ok=True)
|
||||
|
||||
def call(self, step, method, path, token, query=None, form=None, files=None):
|
||||
"""One Graph call. `path` is printed and saved; `query` is sent but NEVER printed or saved
|
||||
when it carries a secret (it is saved only as the list of its keys)."""
|
||||
url = "%s/%s/%s" % (GRAPH, self.version, path.lstrip("/"))
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
data, ctype = None, None
|
||||
if files:
|
||||
data, ctype = multipart(form or {}, files)
|
||||
elif form is not None:
|
||||
data, ctype = urllib.parse.urlencode(form).encode("utf-8"), "application/x-www-form-urlencoded"
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
if ctype:
|
||||
req.add_header("Content-Type", ctype)
|
||||
status, raw, hdrs = None, b"", {}
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
status, raw, hdrs = r.status, r.read(), r.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
status, raw, hdrs = e.code, e.read(), e.headers
|
||||
except urllib.error.URLError as e:
|
||||
status, raw = None, json.dumps({"transport_error": str(e.reason)}).encode()
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except ValueError:
|
||||
body = {"non_json_body": raw[:500].decode("utf-8", "replace")}
|
||||
kept = {h: hdrs.get(h) for h in HEADERS_KEPT if hdrs and hdrs.get(h) is not None}
|
||||
for h, v in kept.items():
|
||||
self.headers_seen.setdefault(h, v)
|
||||
err = None
|
||||
if status is None or not (200 <= status < 300):
|
||||
err = body.get("error", body) if isinstance(body, dict) else body
|
||||
elif isinstance(body, dict) and "error" in body: # Meta can say 200 and mean no (§9.3)
|
||||
err = body["error"]
|
||||
c = Call(step, method, path, status, body, kept, err)
|
||||
vlog("%s %s -> HTTP %s%s" % (method, path, status,
|
||||
"" if err is None else " ERROR " + json.dumps(redact(err), ensure_ascii=False)))
|
||||
self.save(c, query, form, files)
|
||||
return c
|
||||
|
||||
def save(self, c, query, form, files):
|
||||
rec = {
|
||||
"step": c.step, "method": c.method, "path": c.path, "api_version": self.version,
|
||||
"query_keys": sorted(query) if query else [],
|
||||
"form": redact({k: v for k, v in (form or {}).items()}),
|
||||
"files": {k: os.path.relpath(v, ROOT) for k, v in (files or {}).items()},
|
||||
"http_status": c.status, "headers": c.headers, "ok": c.ok,
|
||||
"error": c.err, "response": c.body,
|
||||
"at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
path = os.path.join(self.evidence, c.step + ".json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(redact(rec), fh, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def multipart(fields, files):
|
||||
boundary = "----felhomprobe" + uuid.uuid4().hex
|
||||
out = []
|
||||
for k, v in fields.items():
|
||||
out += [b"--" + boundary.encode(), ('Content-Disposition: form-data; name="%s"' % k).encode(), b"",
|
||||
str(v).encode("utf-8")]
|
||||
for k, p in files.items():
|
||||
with open(p, "rb") as fh:
|
||||
blob = fh.read()
|
||||
out += [b"--" + boundary.encode(),
|
||||
('Content-Disposition: form-data; name="%s"; filename="%s"' % (k, os.path.basename(p))).encode(),
|
||||
b"Content-Type: image/png", b"", blob]
|
||||
out += [b"--" + boundary.encode() + b"--", b""]
|
||||
return b"\r\n".join(out), "multipart/form-data; boundary=" + boundary
|
||||
|
||||
|
||||
def note(g, step, data):
|
||||
"""A non-call record (a verdict, a comparison) saved beside the calls."""
|
||||
with open(os.path.join(g.evidence, step + ".json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(redact(data), fh, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scenarios
|
||||
|
||||
def scenario_a(g, token):
|
||||
c = g.call("A1-debug-token", "GET", "debug_token", token, query={"input_token": token})
|
||||
d = (c.body or {}).get("data", {}) if c.ok else {}
|
||||
checks = {"call_ok": c.ok, "is_valid": d.get("is_valid") is True, "app_id": d.get("app_id") == APP_ID}
|
||||
log("A debug_token: ok=%s is_valid=%s type=%s app_id=%s expires_at=%s data_access_expires_at=%s"
|
||||
% (c.ok, d.get("is_valid"), d.get("type"), d.get("app_id"), d.get("expires_at"),
|
||||
d.get("data_access_expires_at")))
|
||||
log(" scopes=%s" % d.get("scopes"))
|
||||
return all(checks.values()), d
|
||||
|
||||
|
||||
def scenario_b(g, token):
|
||||
me = g.call("B1-me", "GET", "me", token, query={"fields": "id,name"})
|
||||
log("B /me: ok=%s id=%s name=%s" % (me.ok, me.body.get("id"), me.body.get("name")))
|
||||
acc = g.call("B2-me-accounts", "GET", "me/accounts", token, query={"fields": "id,name,tasks,access_token"})
|
||||
if not (me.ok and acc.ok):
|
||||
return None
|
||||
pages = acc.body.get("data", [])
|
||||
log(" /me/accounts: %d page(s): %s" % (len(pages), [p.get("name") for p in pages]))
|
||||
mine = [p for p in pages if p.get("name") == PAGE_NAME]
|
||||
if len(mine) != 1:
|
||||
log(" STOP: expected exactly one page named %s, found %d" % (PAGE_NAME, len(mine)))
|
||||
return None
|
||||
p = mine[0]
|
||||
ptok = p.get("access_token")
|
||||
if not ptok:
|
||||
log(" STOP: the page entry carries no access_token")
|
||||
return None
|
||||
SECRETS.append(ptok)
|
||||
log(" page id=%s tasks=%s (page token held in memory, %d chars)" % (p["id"], p.get("tasks"), len(ptok)))
|
||||
dbg = g.call("B3-debug-page-token", "GET", "debug_token", token, query={"input_token": ptok})
|
||||
dd = dbg.body.get("data", {}) if dbg.ok else {}
|
||||
log(" page token: ok=%s type=%s is_valid=%s expires_at=%s data_access_expires_at=%s scopes=%s"
|
||||
% (dbg.ok, dd.get("type"), dd.get("is_valid"), dd.get("expires_at"),
|
||||
dd.get("data_access_expires_at"), dd.get("scopes")))
|
||||
return {"id": p["id"], "tasks": p.get("tasks") or [], "token": ptok}
|
||||
|
||||
|
||||
INSIGHT_METRICS = ("page_post_engagements", "page_follows", "page_media_view")
|
||||
|
||||
|
||||
def scenario_c(g, page):
|
||||
pid, ptok = page["id"], page["token"]
|
||||
c1 = g.call("C1-page", "GET", pid, ptok,
|
||||
query={"fields": "id,name,link,category,about,website,followers_count,fan_count"})
|
||||
log("C page fields: ok=%s %s" % (c1.ok, {k: v for k, v in c1.body.items() if k != "error"} if c1.ok else c1.err))
|
||||
c2 = g.call("C2-feed", "GET", pid + "/feed", ptok, query={"limit": "5"})
|
||||
log(" feed: ok=%s posts=%s" % (c2.ok, len(c2.body.get("data", [])) if c2.ok else c2.err))
|
||||
for m in INSIGHT_METRICS: # one call per metric: one renamed metric must not hide the others
|
||||
c = g.call("C3-insights-" + m, "GET", pid + "/insights", ptok, query={"metric": m, "period": "day"})
|
||||
log(" insights %s: ok=%s %s" % (m, c.ok, "values=%d" % len(c.body.get("data", [])) if c.ok
|
||||
else json.dumps(c.err, ensure_ascii=False)))
|
||||
|
||||
|
||||
def hexs(s):
|
||||
return (s or "").encode("utf-8").hex()
|
||||
|
||||
|
||||
def prove_removed(g, step, obj_id, ptok):
|
||||
c = g.call(step, "GET", obj_id, ptok, query={"fields": "id"})
|
||||
gone = (not c.ok) and isinstance(c.err, dict) and c.err.get("code") == 100
|
||||
log(" GET after DELETE %s: HTTP %s, removed=%s, error=%s"
|
||||
% (obj_id, c.status, gone, json.dumps(c.err, ensure_ascii=False)))
|
||||
return gone
|
||||
|
||||
|
||||
def delete(g, step, obj_id, ptok):
|
||||
c = g.call(step, "DELETE", obj_id, ptok)
|
||||
if not (c.ok and c.body.get("success") is True):
|
||||
log(" DELETE %s failed (%s) — once more after 30 s" % (obj_id, c.err))
|
||||
time.sleep(30)
|
||||
c = g.call(step + "-retry", "DELETE", obj_id, ptok)
|
||||
log(" DELETE %s: ok=%s body=%s" % (obj_id, c.ok, c.body))
|
||||
return c.ok and c.body.get("success") is True
|
||||
|
||||
|
||||
def check_readback(label, rb, sent_hex, field, when):
|
||||
got = rb.body.get(field) if rb.ok else None
|
||||
res = {
|
||||
"readback_ok": rb.ok, "is_published": rb.body.get("is_published") if rb.ok else None,
|
||||
"scheduled_publish_time_sent": when,
|
||||
"scheduled_publish_time_read": rb.body.get("scheduled_publish_time") if rb.ok else None,
|
||||
"text_field": field, "sent_hex": sent_hex, "read_hex": hexs(got) if got is not None else None,
|
||||
}
|
||||
res["hex_equal"] = res["read_hex"] == sent_hex
|
||||
log(" %s read back: is_published=%s scheduled=%s (sent %s) hex_equal=%s"
|
||||
% (label, res["is_published"], res["scheduled_publish_time_read"], when, res["hex_equal"]))
|
||||
return res
|
||||
|
||||
|
||||
def scenario_d(g, page, rc):
|
||||
pid, ptok = page["id"], page["token"]
|
||||
when = int(time.time()) + WEEK_S
|
||||
c = g.call("D1-create-feed", "POST", pid + "/feed", ptok,
|
||||
form={"message": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)})
|
||||
if not c.ok:
|
||||
log("D REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False))
|
||||
return 6, None
|
||||
post_id = c.body.get("id")
|
||||
log("D created %s" % post_id)
|
||||
rb = g.call("D2-readback", "GET", post_id, ptok,
|
||||
query={"fields": "message,is_published,scheduled_publish_time,created_time"})
|
||||
res = check_readback("D", rb, hexs(TEST_TEXT), "message", when)
|
||||
res["post_id"] = post_id
|
||||
published = res["is_published"] is True
|
||||
deleted = delete(g, "D3-delete", post_id, ptok)
|
||||
res["delete_ok"] = deleted
|
||||
res["removed"] = prove_removed(g, "D4-get-after-delete", post_id, ptok)
|
||||
note(g, "D9-verdict", res)
|
||||
if published:
|
||||
log("D !!! the post read back PUBLISHED — deleted; removed=%s" % res["removed"])
|
||||
return 8, res
|
||||
if not res["removed"]:
|
||||
log("D !!! REMOVAL NOT PROVEN for %s — remove it by hand in Meta Business Suite > Planner" % post_id)
|
||||
return 7, res
|
||||
return rc, res
|
||||
|
||||
|
||||
def scenario_e(g, page, rc):
|
||||
pid, ptok = page["id"], page["token"]
|
||||
when = int(time.time()) + WEEK_S
|
||||
c = g.call("E1-create-photo", "POST", pid + "/photos", ptok,
|
||||
form={"caption": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)},
|
||||
files={"source": LOGO})
|
||||
if not c.ok:
|
||||
log("E REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False))
|
||||
return 6, None
|
||||
photo_id, post_id = c.body.get("id"), c.body.get("post_id")
|
||||
log("E created photo id=%s post_id=%s (keys returned: %s)" % (photo_id, post_id, sorted(c.body)))
|
||||
res = {"photo_id": photo_id, "post_id": post_id, "create_keys": sorted(c.body), "logo": os.path.relpath(LOGO, ROOT)}
|
||||
if post_id:
|
||||
rb = g.call("E2-readback-post", "GET", post_id, ptok,
|
||||
query={"fields": "message,is_published,scheduled_publish_time,created_time"})
|
||||
res["post"] = check_readback("E post", rb, hexs(TEST_TEXT), "message", when)
|
||||
if photo_id:
|
||||
rp = g.call("E3-readback-photo", "GET", photo_id, ptok, query={"fields": "id,name,created_time,link"})
|
||||
got = rp.body.get("name") if rp.ok else None
|
||||
res["photo"] = {"readback_ok": rp.ok, "name_hex": hexs(got) if got is not None else None}
|
||||
res["photo"]["hex_equal"] = res["photo"]["name_hex"] == hexs(TEST_TEXT)
|
||||
log(" E photo read back: ok=%s caption hex_equal=%s" % (rp.ok, res["photo"]["hex_equal"]))
|
||||
published = bool(res.get("post", {}).get("is_published"))
|
||||
# Delete the post first, then look at both ids: which object DELETE needs is a finding (§7 E).
|
||||
first = post_id or photo_id
|
||||
res["delete_first_target"] = "post_id" if post_id else "photo_id"
|
||||
res["delete_first_ok"] = delete(g, "E4-delete-" + res["delete_first_target"], first, ptok)
|
||||
res["post_removed"] = prove_removed(g, "E5-get-post-after-delete", post_id, ptok) if post_id else None
|
||||
res["photo_removed"] = prove_removed(g, "E6-get-photo-after-delete", photo_id, ptok) if photo_id else None
|
||||
if photo_id and post_id and not res["photo_removed"]:
|
||||
log(" the photo outlived its post's DELETE — deleting the photo id too")
|
||||
res["delete_photo_ok"] = delete(g, "E7-delete-photo_id", photo_id, ptok)
|
||||
res["photo_removed"] = prove_removed(g, "E8-get-photo-after-delete", photo_id, ptok)
|
||||
note(g, "E9-verdict", res)
|
||||
removed = res["photo_removed"] is not False and res["post_removed"] is not False
|
||||
if published:
|
||||
log("E !!! the photo post read back PUBLISHED — deleted; removed=%s" % removed)
|
||||
return 8, res
|
||||
if not removed:
|
||||
log("E !!! REMOVAL NOT PROVEN (photo %s, post %s) — remove by hand in Meta Business Suite > Planner"
|
||||
% (photo_id, post_id))
|
||||
return 7, res
|
||||
return rc, res
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
def main(argv=None):
|
||||
global VERBOSE
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("-v", action="store_true", help="print each call: method, path (no query), status, error")
|
||||
ap.add_argument("--version", default="v26.0", help="Graph API version (default v26.0)")
|
||||
ap.add_argument("--evidence", default=DEFAULT_EVIDENCE)
|
||||
ap.add_argument("--credentials", default=os.path.expanduser("~/.config/credentials"))
|
||||
ap.add_argument("cmd", choices=("read", "write-test"))
|
||||
a = ap.parse_args(argv)
|
||||
VERBOSE = a.v
|
||||
try:
|
||||
token = load_key(a.credentials)
|
||||
except (CredentialError, OSError) as e:
|
||||
log("KEY REFUSED [%s]: %s" % (KEY, e))
|
||||
return 2
|
||||
SECRETS.append(token)
|
||||
log("key %s: %d chars, starts %s" % (KEY, len(token), token[:3]))
|
||||
# write-test re-derives A and B into its own sub-directory, so the read run's files stay as they were
|
||||
g = Graph(a.version, a.evidence if a.cmd == "read" else os.path.join(a.evidence, "write-test"))
|
||||
try:
|
||||
return run(a.cmd, g, token)
|
||||
finally:
|
||||
note(g, "F1-headers", g.headers_seen) # §7 F: recorded once each, on every exit path
|
||||
log("F headers: %s" % json.dumps(g.headers_seen))
|
||||
|
||||
|
||||
def run(cmd, g, token):
|
||||
ok_a, _ = scenario_a(g, token)
|
||||
if not ok_a:
|
||||
log("A FAILED")
|
||||
return 3
|
||||
page = scenario_b(g, token)
|
||||
if page is None:
|
||||
log("B FAILED")
|
||||
return 4
|
||||
if cmd == "read":
|
||||
scenario_c(g, page)
|
||||
log("read: done")
|
||||
return 0
|
||||
if "CREATE_CONTENT" not in page["tasks"]:
|
||||
log("WRITE GATE: the page's tasks lack CREATE_CONTENT (%s) — no write" % page["tasks"])
|
||||
return 5
|
||||
rc, _ = scenario_d(g, page, 0)
|
||||
if rc != 0:
|
||||
return rc
|
||||
rc, _ = scenario_e(g, page, 0)
|
||||
log("write-test: done rc=%d" % rc)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for fb_probe.py's two secret-handling seams: the key loader (R-453) and redact()."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fb_probe # noqa: E402
|
||||
from read_credential import CredentialError # noqa: E402
|
||||
|
||||
FAKE = "EAAfakeprobe0123456789abcdef"
|
||||
|
||||
|
||||
def creds(text):
|
||||
fd, p = tempfile.mkstemp()
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write(text)
|
||||
return p
|
||||
|
||||
|
||||
class LoadKey(unittest.TestCase):
|
||||
def load(self, text):
|
||||
p = creds(text)
|
||||
try:
|
||||
return fb_probe.load_key(p)
|
||||
finally:
|
||||
os.unlink(p)
|
||||
|
||||
def test_single_quotes(self):
|
||||
self.assertEqual(self.load("OTHER='x'\nFACEBOOK_API='%s'\n" % FAKE), FAKE)
|
||||
|
||||
def test_double_quotes_and_export(self):
|
||||
self.assertEqual(self.load('export FACEBOOK_API="%s"\n' % FAKE), FAKE)
|
||||
|
||||
def test_trailing_whitespace_stripped(self):
|
||||
self.assertEqual(self.load("FACEBOOK_API='%s' \n" % FAKE), FAKE)
|
||||
|
||||
def test_mismatched_quote_refused(self):
|
||||
with self.assertRaises(CredentialError):
|
||||
self.load("FACEBOOK_API='%s\"\n" % FAKE)
|
||||
|
||||
def test_inner_whitespace_refused(self):
|
||||
with self.assertRaises(CredentialError):
|
||||
self.load("FACEBOOK_API='EAA abc'\n")
|
||||
|
||||
def test_not_a_meta_token_refused(self):
|
||||
with self.assertRaises(CredentialError):
|
||||
self.load("FACEBOOK_API='xyz123'\n")
|
||||
|
||||
def test_missing_key_refused(self):
|
||||
with self.assertRaises(CredentialError):
|
||||
self.load("FACEBOOK_APIX='%s'\n" % FAKE)
|
||||
|
||||
|
||||
class Redact(unittest.TestCase):
|
||||
def test_access_token_key_dropped_everywhere(self):
|
||||
out = fb_probe.redact({"data": [{"id": "1", "access_token": "zzz"}], "access_token": "y"}, secrets=[])
|
||||
self.assertEqual(out, {"data": [{"id": "1"}]})
|
||||
|
||||
def test_held_secret_and_token_shape_replaced(self):
|
||||
out = fb_probe.redact({"a": "pre-sekret-post", "b": "x " + FAKE, "c": "fine"}, secrets=["sekret"])
|
||||
self.assertEqual(out, {"a": "[REDACTED]", "b": "[REDACTED]", "c": "fine"})
|
||||
|
||||
def test_hungarian_text_survives(self):
|
||||
self.assertEqual(fb_probe.redact(fb_probe.TEST_TEXT, secrets=[]), fb_probe.TEST_TEXT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user