"""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 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 # --- 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