CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 39s
Second adapter consumer. POST /api/webhooks/forge validates Gitea's
X-Gitea-Signature (HMAC-SHA256, constant-time; no secret configured =
the endpoint 404s out of existence), extracts changed/removed paths,
and flags matched snippets by writing verification.invalidated_by
{commit_sha, at, path, removed} — the existing attention vocabulary
extended, not a new flag: needs_attention includes it, both filter
dialects (Python + jsonpath SQL) include it in 'attention' and exclude
it from 'ok', and recording ANY fresh verdict clears it by construction
because compose_verification builds a new dict. Unverified snippets are
skipped (already in their own bucket); replayed deliveries at the same
head commit are no-ops; processing failures return 200 with a WARNING +
AppLog canary so the forge never marks deliveries failed and operators
never disable the hook over a transient (#2663's lesson).
Matching goes through repo BINDINGS: recorded location repos are
free-form names ('Scribe') that cannot address a forge, so a snippet
reaches its forge repo through its project's binding — which also fixes
step 5's pull-time resolution for every real record via the same
fallback. O(bindings + snippets-in-project + changed files).
Settings: webhook secret beside the forge config (masked, sentinel-
skipped, Docker-secret env channel, endpoint documented in the UI).
Tests: signature gate, payload parsing, path semantics, both filter
dialects extended in the drift-check guard file, and real-Postgres
end-to-end (flag lands, attention lists it, replay quiet, re-verify
clears, unbound repo untouched).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Tests for the drift check (#2086) — does a snippet still match its source?
|
|
|
|
The check itself runs agent-side (Scribe has no checkout), so what's testable
|
|
here is everything around it: the verdict's shape, the rule that a verdict
|
|
expires when the code it blessed is edited away, and the two dialects of the
|
|
filter predicate — SQL for the browse/keyword arms, Python for the semantic
|
|
arm's already-fetched candidates.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from scribe.services import knowledge as knowledge_svc
|
|
from scribe.services import snippets as snippets_svc
|
|
from scribe.services.snippets import (
|
|
code_sha,
|
|
compose_data,
|
|
compose_verification,
|
|
verification_view,
|
|
)
|
|
|
|
|
|
def _note(data=None, body=""):
|
|
return SimpleNamespace(data=data, body=body, title="n — w", tags=["snippet"])
|
|
|
|
|
|
# --- the verdict record ---------------------------------------------------
|
|
|
|
|
|
def test_unknown_status_is_rejected_not_stored():
|
|
"""A typo'd status would be stored happily and then never match any filter —
|
|
invisible rot in the thing built to make rot visible."""
|
|
with pytest.raises(ValueError):
|
|
compose_verification(status="broekn", checked_code_sha="abc")
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["ok", "missing", "moved", "changed"])
|
|
def test_every_documented_status_is_accepted(status):
|
|
out = compose_verification(status=status, checked_code_sha="abc")
|
|
assert out["status"] == status
|
|
assert out["code_sha"] == "abc"
|
|
assert out["checked_at"]
|
|
|
|
|
|
def test_blank_detail_and_path_are_omitted_not_stored_empty():
|
|
"""Keeps `data` sparse, and keeps a containment match from tripping on ""."""
|
|
out = compose_verification(status="ok", checked_code_sha="abc")
|
|
assert "detail" not in out and "path" not in out
|
|
|
|
|
|
# --- expiry by edit -------------------------------------------------------
|
|
|
|
|
|
def test_code_sha_ignores_reformatting_but_not_meaning():
|
|
"""A verdict must not expire because an editor stripped trailing whitespace,
|
|
and must expire when the code actually changes."""
|
|
assert code_sha("def f():\n return 1") == code_sha("def f(): \n return 1\n")
|
|
assert code_sha("def f():\n return 1") != code_sha("def f():\n return 2")
|
|
|
|
|
|
def test_verdict_stays_current_while_code_is_unchanged():
|
|
code = "def f():\n return 1"
|
|
fields = {"code": code, "verification": compose_verification(
|
|
status="ok", checked_code_sha=code_sha(code)
|
|
)}
|
|
view = verification_view(_note(), fields)
|
|
assert view["status"] == "ok"
|
|
assert view["current"] is True
|
|
assert view["needs_attention"] is False
|
|
|
|
|
|
def test_an_ok_verdict_expires_once_the_code_is_edited():
|
|
"""The whole reason the verdict carries a hash: an OK verdict must never go
|
|
on vouching for code nobody checked."""
|
|
fields = {"code": "def f():\n return 2", "verification": compose_verification(
|
|
status="ok", checked_code_sha=code_sha("def f():\n return 1")
|
|
)}
|
|
view = verification_view(_note(), fields)
|
|
assert view["current"] is False
|
|
assert view["needs_attention"] is True
|
|
|
|
|
|
def test_never_checked_reads_as_unverified():
|
|
view = verification_view(_note(), {"code": "x = 1"})
|
|
assert view["status"] == "unverified"
|
|
assert view["current"] is False
|
|
|
|
|
|
def test_a_failing_verdict_needs_attention_even_while_current():
|
|
code = "x = 1"
|
|
fields = {"code": code, "verification": compose_verification(
|
|
status="missing", checked_code_sha=code_sha(code)
|
|
)}
|
|
assert verification_view(_note(), fields)["needs_attention"] is True
|
|
|
|
|
|
# --- the data mirror ------------------------------------------------------
|
|
|
|
|
|
def test_compose_data_mirrors_the_current_code_hash():
|
|
"""`data.code_sha` is what makes "this verdict expired" an SQL-expressible
|
|
comparison rather than a post-filter that would break pagination totals."""
|
|
out = compose_data(name="n", code="def f():\n return 1")
|
|
assert out["code_sha"] == code_sha("def f():\n return 1")
|
|
|
|
|
|
def test_compose_data_omits_the_hash_when_there_is_no_code():
|
|
assert "code_sha" not in compose_data(name="n")
|
|
|
|
|
|
def test_the_code_itself_is_never_copied_into_data():
|
|
"""data indexes AROUND the code; duplicating the blob would be pure weight."""
|
|
out = compose_data(name="n", code="secret_looking_blob")
|
|
assert "code" not in out
|
|
assert "secret_looking_blob" not in str(out)
|
|
|
|
|
|
# --- the filter, Python dialect -------------------------------------------
|
|
|
|
|
|
def _data(status, checked_sha, current_sha):
|
|
return {
|
|
"verification": {"status": status, "code_sha": checked_sha},
|
|
"code_sha": current_sha,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[
|
|
("", True), # no filter
|
|
("ok", True),
|
|
("attention", False),
|
|
("drifted", False),
|
|
("unverified", False),
|
|
],
|
|
)
|
|
def test_python_dialect_on_a_current_ok_verdict(value, expected):
|
|
data = _data("ok", "aaa", "aaa")
|
|
assert knowledge_svc.verification_matches(data, value) is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[("ok", False), ("attention", True), ("drifted", False), ("unverified", False)],
|
|
)
|
|
def test_python_dialect_on_an_expired_ok_verdict(value, expected):
|
|
"""Expired-but-ok is the case that motivated `attention` existing at all: it
|
|
is not `drifted` (nothing was found wrong) and not `unverified` (a check did
|
|
happen), yet it plainly needs looking at."""
|
|
data = _data("ok", "aaa", "bbb")
|
|
assert knowledge_svc.verification_matches(data, value) is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[("ok", False), ("attention", True), ("drifted", True), ("changed", True),
|
|
("missing", False)],
|
|
)
|
|
def test_python_dialect_on_a_drifted_verdict(value, expected):
|
|
data = _data("changed", "aaa", "aaa")
|
|
assert knowledge_svc.verification_matches(data, value) is expected
|
|
|
|
|
|
def test_python_dialect_on_a_row_with_no_data_at_all():
|
|
"""Rows predating migration 0070 have no `data`. They are unverified, which
|
|
is true — nobody has checked them."""
|
|
assert knowledge_svc.verification_matches(None, "unverified") is True
|
|
assert knowledge_svc.verification_matches(None, "attention") is False
|
|
assert knowledge_svc.verification_matches(None, "ok") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[("ok", False), ("attention", True), ("drifted", False), ("unverified", False)],
|
|
)
|
|
def test_python_dialect_on_a_push_invalidated_ok_verdict(value, expected):
|
|
"""#2691: a push touched the recorded location since the verdict. Like the
|
|
expired case, it is neither drifted (nothing found wrong) nor unverified (a
|
|
check happened) — but the repo moved under the blessing, so `attention`
|
|
must include it and `ok` must not."""
|
|
data = _data("ok", "aaa", "aaa")
|
|
data["verification"]["invalidated_by"] = {"commit_sha": "d" * 40, "at": "t"}
|
|
assert knowledge_svc.verification_matches(data, value) is expected
|
|
|
|
|
|
def test_push_invalidation_reads_as_attention_and_clears_on_reverify():
|
|
"""The flag rides the verdict dict, so recording ANY fresh verdict clears
|
|
it by construction — compose_verification builds a new dict. No clearing
|
|
branch exists to forget."""
|
|
sha = code_sha("def f(): pass")
|
|
verdict = compose_verification(status="ok", checked_code_sha=sha)
|
|
verdict["invalidated_by"] = {"commit_sha": "d" * 40, "at": "t"}
|
|
fields = {"code": "def f(): pass", "verification": verdict}
|
|
view = verification_view(_note(), fields)
|
|
assert view["needs_attention"] is True
|
|
assert view["invalidated_by"]["commit_sha"] == "d" * 40
|
|
|
|
fresh = compose_verification(status="ok", checked_code_sha=sha)
|
|
assert "invalidated_by" not in fresh
|
|
view2 = verification_view(_note(), {"code": "def f(): pass", "verification": fresh})
|
|
assert view2["needs_attention"] is False
|
|
|
|
|
|
# --- the filter, SQL dialect ----------------------------------------------
|
|
|
|
|
|
def test_no_filter_produces_no_clause():
|
|
assert knowledge_svc._verification_clause("") is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value", ["ok", "drifted", "unverified", "attention", "missing", "moved", "changed"]
|
|
)
|
|
def test_every_filter_value_compiles_to_sql(value):
|
|
"""The two dialects have to agree, and the SQL one has to be expressible at
|
|
all — the expired comparison relies on jsonpath reading two fields of the
|
|
same row, which is easy to get wrong silently."""
|
|
clause = knowledge_svc._verification_clause(value)
|
|
assert clause is not None
|
|
assert str(clause)
|
|
|
|
|
|
def test_a_status_value_is_json_quoted_into_the_jsonpath():
|
|
"""Statuses are validated on WRITE, but the filter takes caller input
|
|
directly — a quote must not be able to break out of the jsonpath and turn
|
|
a narrowing filter into a widening one."""
|
|
hostile = 'x" || @.status == "ok'
|
|
clause = knowledge_svc._verification_clause(hostile)
|
|
params = list(clause.compile().params.values())
|
|
jsonpath = next(p for p in params if isinstance(p, str) and "status" in p)
|
|
# The injected quote is escaped, so the whole hostile value stays ONE string
|
|
# literal rather than closing it and appending a second condition. Asserting
|
|
# on the exact json.dumps form rather than counting "@.status" — the hostile
|
|
# value legitimately contains that text inside the literal.
|
|
import json as _json
|
|
|
|
assert jsonpath == f"$.verification ? (@.status == {_json.dumps(hostile)})"
|
|
assert '\\"' in jsonpath
|
|
|
|
|
|
# --- write path -----------------------------------------------------------
|
|
|
|
|
|
async def test_recording_a_verdict_does_not_touch_the_body():
|
|
"""A verdict is metadata about the snippet, not part of it. Writing it into
|
|
the body would put it into the embedding and change what the record recalls
|
|
against."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
note = SimpleNamespace(
|
|
id=5, user_id=1, note_type="snippet", deleted_at=None,
|
|
title="n — w", body="```python\nx = 1\n```", tags=["snippet"], data=None,
|
|
)
|
|
with (
|
|
patch.object(snippets_svc, "get_snippet", AsyncMock(return_value=note)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
snippets_svc.notes_svc, "update_note", AsyncMock(return_value=note)
|
|
) as upd,
|
|
):
|
|
await snippets_svc.record_verification(1, 5, status="moved", detail="renamed")
|
|
|
|
assert set(upd.call_args.kwargs) == {"data"}
|
|
stored = upd.call_args.kwargs["data"]["verification"]
|
|
assert stored["status"] == "moved"
|
|
assert stored["detail"] == "renamed"
|
|
|
|
|
|
async def test_recording_a_verdict_requires_write_access():
|
|
"""Being able to read a snippet someone shared must not let you mark it
|
|
broken — a verdict changes how the record is presented to them."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
note = SimpleNamespace(id=5, user_id=2, note_type="snippet", deleted_at=None,
|
|
title="n — w", body="", tags=[], data=None)
|
|
with (
|
|
patch.object(snippets_svc, "get_snippet", AsyncMock(return_value=note)),
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)),
|
|
patch.object(snippets_svc.notes_svc, "update_note", AsyncMock()) as upd,
|
|
):
|
|
assert await snippets_svc.record_verification(1, 5, status="ok") is None
|
|
upd.assert_not_called()
|