Files
FabledScribe/tests/test_forge_webhook.py
T
bvandeusenandClaude Fable 5 765635bbf2
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 45s
feat(forge): GitHub adapter — second implementation keeps the seam a contract (#2693, milestone 288 step 8)
ForgeAdapter is now a named base class carrying the shared plumbing
(host join, error taxonomy, contents decoding, archive, default_branch,
latest_commit); GiteaForge keeps its exact behavior and GitHubForge joins
with the real differences: api.github.com / GHE /api/v3 host mapping,
Bearer auth, a commits call for the provenance stamp (GitHub's contents
payload only carries the blob sha), and the codeload tarball redirect.

The contract grew latest_commit, and with it the cached-SHA short-circuit
in pull-time freshness: a stored provenance commit that still heads the
recorded path confirms 'current' without a content transfer — the economy
that fits pulls inside GitHub's rate limits; every surprise falls back to
the full fetch. Webhook deliveries now also accept X-Hub-Signature-256
(sha256=<hex>); the payload shape was already common. Settings card copy
covers both forges' token scopes; the kind selector already flowed from
the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:18:16 -04:00

251 lines
9.1 KiB
Python

"""Forge push webhook (#2691) — signature gate, payload parsing, and the
end-to-end drift flag on real Postgres.
The webhook is the seam that makes verification scale: a push names exactly
which files moved, so only the records that point at them get flagged. The
properties pinned here:
- No secret configured → the endpoint does not exist (404); a bad
signature → 401. Both BEFORE any payload parsing.
- Matching is O(bindings + snippets-in-project + changed files) and goes
through repo bindings — recorded location repos are free-form names and
cannot address a forge.
- A replayed delivery (same head commit) flags nothing new; re-verifying
clears the flag by construction.
"""
import hashlib
import hmac
import pytest
import pytest_asyncio
from scribe.routes.webhooks import delivered_signature, push_facts, signature_ok
from scribe.services.snippets import _path_touches
SECRET = "wh-secret"
HEAD = "e" * 40
# --- unit: the signature gate ------------------------------------------------
def _sign(body: bytes, secret: str = SECRET) -> str:
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
def test_signature_gate():
body = b'{"x": 1}'
assert signature_ok(SECRET, body, _sign(body)) is True
assert signature_ok(SECRET, body, _sign(body).upper()) is True # hex case
assert signature_ok(SECRET, body, _sign(body, "wrong")) is False
assert signature_ok(SECRET, body, "") is False
assert signature_ok("", body, _sign(body)) is False
def test_delivered_signature_reads_both_forges_headers():
"""#2693: GitHub signs the same HMAC but ships it as
X-Hub-Signature-256: sha256=<hex> — the whole webhook payload mapping is
this header, so pin it."""
hexsig = _sign(b"{}")
assert delivered_signature({"X-Gitea-Signature": hexsig}) == hexsig
assert delivered_signature({"X-Hub-Signature-256": f"sha256={hexsig}"}) == hexsig
# Gitea's header wins when both appear; absence reads as empty (→ 401).
assert delivered_signature({}) == ""
# The stripped GitHub form still passes the gate end to end.
assert signature_ok(
SECRET, b'{"x": 1}',
delivered_signature({"X-Hub-Signature-256": "sha256=" + _sign(b'{"x": 1}')}),
)
def test_push_facts_reads_a_github_shaped_payload():
"""GitHub's push payload carries the same fields push_facts consumes —
asserted against a real-shaped sample so a rename on either side of the
mapping breaks a test instead of silently flagging nothing."""
payload = {
"ref": "refs/heads/main",
"after": HEAD,
"repository": {
"full_name": "alice/widget",
"clone_url": "https://github.com/alice/widget.git",
"html_url": "https://github.com/alice/widget",
},
"commits": [
{"id": "a" * 40, "added": [], "modified": ["src/x.py"], "removed": []},
],
"head_commit": {"id": HEAD},
}
raw, changed, removed, head = push_facts(payload)
assert raw == "https://github.com/alice/widget.git"
assert changed == ["src/x.py"]
assert removed == []
assert head == HEAD
# --- unit: payload parsing ---------------------------------------------------
def test_push_facts_collects_and_dedups_paths():
payload = {
"after": HEAD,
"repository": {"clone_url": "https://git.example.com/alice/widget.git"},
"commits": [
{"added": ["a.py"], "modified": ["b.py"], "removed": []},
{"added": [], "modified": ["b.py", "c/d.py"], "removed": ["gone.py"]},
],
}
raw, changed, removed, head = push_facts(payload)
assert raw.endswith("alice/widget.git")
assert changed == ["a.py", "b.py", "c/d.py"]
assert removed == ["gone.py"]
assert head == HEAD
def test_push_facts_tolerates_an_empty_payload():
assert push_facts({}) == ("", [], [], "")
def test_path_touches_uses_the_location_semantics():
assert _path_touches("src/x.py", "src/x.py")
assert _path_touches("src", "src/lib/x.py") # recorded dir, file below
assert not _path_touches("src/x.py", "src/x_test.py")
assert not _path_touches("src/lib", "src/library/x.py") # no prefix bleed
assert not _path_touches("", "src/x.py")
def test_route_is_registered_and_unauthenticated_by_design():
from scribe.app import create_app
from scribe.routes import webhooks as wh
assert callable(wh.forge_push)
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/webhooks/forge" in rules
# --- integration: the flag lands and clears on real Postgres -----------------
@pytest_asyncio.fixture
async def _dispose_engine():
from scribe.models import engine
yield
await engine.dispose()
@pytest_asyncio.fixture
async def seeded(_dispose_engine):
"""User + project + binding + two verified snippets + one unverified."""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services import snippets as svc
from scribe.services.repo_bindings import set_binding
async with async_session() as s:
user = (
await s.execute(select(User).where(User.username == "webhook_itest"))
).scalar_one_or_none()
if user is None:
user = User(username="webhook_itest")
s.add(user)
await s.flush()
project = Project(user_id=user.id, title="Widget")
s.add(project)
await s.flush()
uid, pid = user.id, project.id
await s.commit()
await set_binding(uid, "https://git.example.com/alice/widget.git", pid)
async def _snippet(name, path, code, verify=True):
note = await svc.create_snippet(
uid, name=name, code=code, language="python",
repo="Widget", path=path, symbol=name, project_id=pid,
)
if verify:
await svc.record_verification(uid, note.id, status="ok")
return note.id
return {
"uid": uid,
"hit": await _snippet("wh_hit", "src/x.py", "def wh_hit():\n return 1\n"),
"miss": await _snippet("wh_miss", "src/other.py", "def wh_miss():\n return 2\n"),
"gone": await _snippet("wh_gone", "src/gone.py", "def wh_gone():\n return 3\n"),
"unchecked": await _snippet(
"wh_unchecked", "src/x.py", "def wh_unchecked():\n return 4\n",
verify=False,
),
}
@pytest.mark.integration
async def test_push_flags_matched_verdicts_and_replay_is_quiet(seeded):
from scribe.services import snippets as svc
flagged = await svc.invalidate_for_push(
"git.example.com/alice/widget",
changed=["src/x.py"], removed=["src/gone.py"], commit_sha=HEAD,
)
# wh_hit (modified) + wh_gone (removed). wh_miss untouched; wh_unchecked
# carries no verdict and is skipped by design.
assert flagged == 2
uid = seeded["uid"]
hit = svc.snippet_to_dict(await svc.get_snippet(uid, seeded["hit"]))
assert hit["verification"]["needs_attention"] is True
assert hit["verification"]["invalidated_by"]["commit_sha"] == HEAD
assert hit["verification"]["invalidated_by"]["removed"] is False
gone = svc.snippet_to_dict(await svc.get_snippet(uid, seeded["gone"]))
assert gone["verification"]["invalidated_by"]["removed"] is True
miss = svc.snippet_to_dict(await svc.get_snippet(uid, seeded["miss"]))
assert miss["verification"]["needs_attention"] is False
unchecked = svc.snippet_to_dict(await svc.get_snippet(uid, seeded["unchecked"]))
assert unchecked["verification"]["status"] == "unverified"
# The attention listing — the operator's single entry point — now shows
# exactly the flagged pair, through the SQL dialect.
items, total = await svc.list_snippets(uid, verification="attention")
ids = {i["id"] for i in items}
assert {seeded["hit"], seeded["gone"]} <= ids
assert seeded["miss"] not in ids
# Replayed delivery: same head commit flags nothing new.
again = await svc.invalidate_for_push(
"git.example.com/alice/widget",
changed=["src/x.py"], removed=["src/gone.py"], commit_sha=HEAD,
)
assert again == 0
@pytest.mark.integration
async def test_reverifying_clears_the_flag(seeded):
from scribe.services import snippets as svc
await svc.invalidate_for_push(
"git.example.com/alice/widget", changed=["src/x.py"], removed=[],
commit_sha=HEAD,
)
uid = seeded["uid"]
await svc.record_verification(
uid, seeded["hit"], status="ok", detail="rechecked after push",
commit_sha=HEAD,
)
hit = svc.snippet_to_dict(await svc.get_snippet(uid, seeded["hit"]))
assert hit["verification"]["needs_attention"] is False
assert hit["verification"]["invalidated_by"] is None
items, _ = await svc.list_snippets(uid, verification="attention")
assert seeded["hit"] not in {i["id"] for i in items}
@pytest.mark.integration
async def test_unbound_repo_flags_nothing(seeded):
from scribe.services import snippets as svc
flagged = await svc.invalidate_for_push(
"github.com/somebody/else", changed=["src/x.py"], removed=[],
commit_sha=HEAD,
)
assert flagged == 0