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
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>
251 lines
9.1 KiB
Python
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
|