7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
461 lines
16 KiB
Python
461 lines
16 KiB
Python
"""Tests for the check-author-identity pre-commit hook.
|
|
|
|
All test data uses throwaway addresses (``*.test`` / ``example.com``) or public
|
|
GitHub noreply addresses; no real personal email appears in this file.
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Load the hook module by path (hyphenated filename isn't importable directly).
|
|
_HOOK_PATH = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ ".pre-commit-hooks"
|
|
/ "check-author-identity.py"
|
|
)
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"check_author_identity", _HOOK_PATH
|
|
)
|
|
mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(mod)
|
|
|
|
|
|
PYPROJECT = """\
|
|
[project]
|
|
name = "x"
|
|
authors = [
|
|
{name = "LearningCircuit", email = "185559241+LearningCircuit@users.noreply.github.com"},
|
|
{name = "djpetti", email = "djpetti@example.com"},
|
|
]
|
|
"""
|
|
DECLARED = {
|
|
"learningcircuit": {"185559241+learningcircuit@users.noreply.github.com"},
|
|
"djpetti": {"djpetti@example.com"},
|
|
}
|
|
|
|
|
|
class TestParseIdentities:
|
|
def test_parses_and_lowercases(self):
|
|
assert mod.parse_identities(PYPROJECT) == DECLARED
|
|
|
|
def test_key_order_independent(self):
|
|
text = (
|
|
'authors = [\n {email = "bob@example.com", name = "Bob"},\n]\n'
|
|
)
|
|
assert mod.parse_identities(text) == {"bob": {"bob@example.com"}}
|
|
|
|
def test_anchored_ignores_other_authors_keys(self):
|
|
text = (
|
|
'co_authors = ["x"]\n'
|
|
'authors = [\n {name = "A", email = "a@b.test"},\n]\n'
|
|
)
|
|
assert mod.parse_identities(text) == {"a": {"a@b.test"}}
|
|
|
|
def test_no_block_returns_empty(self):
|
|
assert mod.parse_identities('[project]\nname = "x"\n') == {}
|
|
|
|
def test_multiple_emails_per_name(self):
|
|
text = (
|
|
"authors = [\n"
|
|
' {name = "A", email = "a@x.test"},\n'
|
|
' {name = "A", email = "a2@x.test"},\n'
|
|
"]\n"
|
|
)
|
|
assert mod.parse_identities(text) == {"a": {"a@x.test", "a2@x.test"}}
|
|
|
|
|
|
class TestIsNoreply:
|
|
def test_user_noreply_allowed(self):
|
|
assert mod._is_noreply("123+user@users.noreply.github.com")
|
|
assert mod._is_noreply("user@users.noreply.github.com")
|
|
|
|
def test_personal_and_webflow_not_user_noreply(self):
|
|
assert not mod._is_noreply("user@example.com")
|
|
assert not mod._is_noreply("noreply@github.com") # web-flow committer
|
|
|
|
|
|
class TestMismatch:
|
|
def test_any_noreply_allowed_for_declared_author(self):
|
|
# djpetti is declared with a non-noreply email; his GH noreply must pass.
|
|
assert (
|
|
mod._mismatch(
|
|
"author",
|
|
"djpetti",
|
|
"7475340+djpetti@users.noreply.github.com",
|
|
DECLARED,
|
|
)
|
|
is None
|
|
)
|
|
|
|
def test_declared_email_allowed(self):
|
|
assert (
|
|
mod._mismatch("author", "djpetti", "djpetti@example.com", DECLARED)
|
|
is None
|
|
)
|
|
|
|
def test_declared_author_personal_email_flagged(self):
|
|
msg = mod._mismatch(
|
|
"author", "LearningCircuit", "personal@nope.test", DECLARED
|
|
)
|
|
assert msg is not None
|
|
assert "LearningCircuit" in msg
|
|
|
|
def test_case_insensitive_name_match(self):
|
|
# A lowercase display name must still be enforced (the hashedviking gap).
|
|
assert (
|
|
mod._mismatch(
|
|
"author", "learningcircuit", "personal@nope.test", DECLARED
|
|
)
|
|
is not None
|
|
)
|
|
|
|
def test_unknown_name_not_enforced(self):
|
|
assert (
|
|
mod._mismatch("author", "Outsider", "out@example.com", DECLARED)
|
|
is None
|
|
)
|
|
|
|
def test_message_never_contains_offending_email(self):
|
|
secret = "do-not-leak@secret.test"
|
|
msg = mod._mismatch("author", "LearningCircuit", secret, DECLARED)
|
|
assert msg is not None
|
|
assert secret not in msg
|
|
assert "secret" not in msg.lower()
|
|
|
|
def test_empty_declared_never_flags(self):
|
|
assert mod._mismatch("author", "Anyone", "x@y.test", {}) is None
|
|
|
|
|
|
class TestCoAuthorRegex:
|
|
def test_matches_trailer(self):
|
|
m = mod._CO_AUTHOR.search("b\n\nCo-authored-by: Some One <a@b.test>\n")
|
|
assert m is not None
|
|
assert m.group("name") == "Some One"
|
|
assert m.group("email") == "a@b.test"
|
|
|
|
def test_indented_trailer_matches(self):
|
|
assert (
|
|
mod._CO_AUTHOR.search(" Co-authored-by: X <x@y.test>") is not None
|
|
)
|
|
|
|
def test_no_false_match_on_prose(self):
|
|
assert mod._CO_AUTHOR.search("This was co-authored by someone") is None
|
|
|
|
|
|
class TestParseLog:
|
|
def test_basic_record(self):
|
|
raw = "sha1\x00An\x00ae@x.test\x00Cn\x00ce@x.test\x00body line\x1e"
|
|
assert mod._parse_log(raw) == [
|
|
("sha1", "An", "ae@x.test", "Cn", "ce@x.test", "body line")
|
|
]
|
|
|
|
def test_nul_in_body_is_preserved(self):
|
|
# A NUL earlier in the body must not truncate it: a real trailer (on its
|
|
# own line) after the NUL must survive and still be detected.
|
|
body = "head\x00more\n\nCo-authored-by: LearningCircuit <bad@nope.test>"
|
|
raw = f"sha1\x00An\x00a@x.test\x00Cn\x00c@x.test\x00{body}\x1e"
|
|
recs = mod._parse_log(raw)
|
|
assert (
|
|
recs[0][5] == body
|
|
) # full body kept (old fields[5] would truncate)
|
|
errs = mod._check(recs[0], DECLARED)
|
|
assert any("Co-authored-by" in e for e in errs)
|
|
|
|
|
|
class TestCheck:
|
|
@staticmethod
|
|
def _rec(an="A", ae="a@x.test", cn="C", ce="c@x.test", body=""):
|
|
return ("deadbeef0123", an, ae, cn, ce, body)
|
|
|
|
def test_author_violation(self):
|
|
errs = mod._check(
|
|
self._rec(an="LearningCircuit", ae="bad@nope.test"), DECLARED
|
|
)
|
|
assert len(errs) == 1
|
|
assert "author" in errs[0]
|
|
assert "deadbeef0" in errs[0]
|
|
|
|
def test_committer_violation(self):
|
|
errs = mod._check(
|
|
self._rec(cn="LearningCircuit", ce="bad@nope.test"), DECLARED
|
|
)
|
|
assert len(errs) == 1
|
|
assert "committer" in errs[0]
|
|
|
|
def test_co_author_trailer_violation(self):
|
|
errs = mod._check(
|
|
self._rec(
|
|
body="m\n\nCo-authored-by: LearningCircuit <bad@nope.test>\n"
|
|
),
|
|
DECLARED,
|
|
)
|
|
assert len(errs) == 1
|
|
assert "Co-authored-by" in errs[0]
|
|
|
|
def test_clean_noreply_passes(self):
|
|
good = "185559241+learningcircuit@users.noreply.github.com"
|
|
errs = mod._check(
|
|
self._rec(
|
|
an="LearningCircuit", ae=good, cn="LearningCircuit", ce=good
|
|
),
|
|
DECLARED,
|
|
)
|
|
assert errs == []
|
|
|
|
def test_external_contributor_passes(self):
|
|
assert (
|
|
mod._check(self._rec(an="Outsider", ae="out@example.com"), DECLARED)
|
|
== []
|
|
)
|
|
|
|
def test_no_offending_email_in_any_message(self):
|
|
secret = "leak-me@secret.test"
|
|
errs = mod._check(
|
|
self._rec(
|
|
an="LearningCircuit",
|
|
ae=secret,
|
|
body=f"x\n\nCo-authored-by: LearningCircuit <{secret}>\n",
|
|
),
|
|
DECLARED,
|
|
)
|
|
assert errs
|
|
assert all(secret not in e for e in errs)
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_ci_env(monkeypatch):
|
|
"""Isolate the GitHub Actions env (set in real CI -> would flake otherwise)."""
|
|
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
|
|
monkeypatch.delenv("GITHUB_EVENT_NAME", raising=False)
|
|
return monkeypatch
|
|
|
|
|
|
class TestParseLogFailClosed:
|
|
def test_malformed_record_raises(self):
|
|
# An injected record separator splits a record into a <6-field chunk.
|
|
raw = "s\x00An\x00a@x.test\x00Cn\x00c@x.test\x00body\x1eINJECTED\x1e"
|
|
with pytest.raises(RuntimeError):
|
|
mod._parse_log(raw)
|
|
|
|
def test_multiple_records(self):
|
|
raw = (
|
|
"s1\x00A\x00a@x.test\x00C\x00c@x.test\x00b1\x1e"
|
|
"s2\x00A\x00a@x.test\x00C\x00c@x.test\x00b2\x1e"
|
|
)
|
|
assert [r[0] for r in mod._parse_log(raw)] == ["s1", "s2"]
|
|
|
|
|
|
class TestResolveMergeBase:
|
|
def test_resolves_locally_without_fetching(self, monkeypatch):
|
|
# With full history present, merge-base succeeds first try -> NO fetch
|
|
# (the hook must not mutate git state inside `pre-commit run`).
|
|
runs = []
|
|
monkeypatch.setattr(
|
|
mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
|
|
)
|
|
monkeypatch.setattr(mod, "_git", lambda *a: (0, "abc1234\n"))
|
|
assert mod._resolve_merge_base("b", "h") == "abc1234"
|
|
assert runs == [] # read-only: no git fetch was issued
|
|
|
|
def test_fetches_then_succeeds(self, monkeypatch):
|
|
# Shallow clone: first merge-base fails, a depth fetch makes it resolve.
|
|
runs = []
|
|
monkeypatch.setattr(
|
|
mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
|
|
)
|
|
seq = iter([(1, ""), (0, "abc1234\n")])
|
|
monkeypatch.setattr(mod, "_git", lambda *a: next(seq))
|
|
assert mod._resolve_merge_base("b", "h") == "abc1234"
|
|
assert any("--depth=1000" in r for r in runs)
|
|
assert not any("--unshallow" in r for r in runs)
|
|
|
|
def test_retries_unshallow_then_succeeds(self, monkeypatch):
|
|
runs = []
|
|
monkeypatch.setattr(
|
|
mod.subprocess, "run", lambda *a, **k: runs.append(a[0])
|
|
)
|
|
seq = iter([(1, ""), (1, ""), (0, "abc1234\n")])
|
|
monkeypatch.setattr(mod, "_git", lambda *a: next(seq))
|
|
assert mod._resolve_merge_base("b", "h") == "abc1234"
|
|
assert any("--unshallow" in r for r in runs) # full-history path taken
|
|
|
|
def test_raises_when_unresolvable(self, monkeypatch):
|
|
monkeypatch.setattr(mod.subprocess, "run", lambda *a, **k: None)
|
|
monkeypatch.setattr(mod, "_git", lambda *a: (1, ""))
|
|
with pytest.raises(RuntimeError):
|
|
mod._resolve_merge_base("b", "h")
|
|
|
|
|
|
class TestPrContext:
|
|
@staticmethod
|
|
def _event(tmp_path):
|
|
ev = tmp_path / "event.json"
|
|
ev.write_text(
|
|
json.dumps(
|
|
{
|
|
"pull_request": {
|
|
"base": {"sha": "BASE"},
|
|
"head": {"sha": "HEAD"},
|
|
}
|
|
}
|
|
)
|
|
)
|
|
return ev
|
|
|
|
def test_reads_allowlist_and_range_from_base(self, clean_ci_env, tmp_path):
|
|
mp = clean_ci_env
|
|
mp.setenv("GITHUB_EVENT_NAME", "pull_request")
|
|
mp.setenv("GITHUB_EVENT_PATH", str(self._event(tmp_path)))
|
|
mp.setattr(mod, "_resolve_merge_base", lambda b, h: "MB")
|
|
calls = []
|
|
|
|
def fake_git(*a):
|
|
calls.append(a)
|
|
if a[0] == "log":
|
|
return (0, "s\x00n\x00e@x.test\x00n\x00e@x.test\x00b\x1e")
|
|
if a[0] == "show":
|
|
return (0, PYPROJECT)
|
|
return (0, "")
|
|
|
|
mp.setattr(mod, "_git", fake_git)
|
|
records, text = mod._pr_context()
|
|
assert ("show", "BASE:pyproject.toml") in calls # base, not work-tree
|
|
assert any(c[0] == "log" and "MB..HEAD" in c[1] for c in calls)
|
|
assert text == PYPROJECT
|
|
assert records and records[0][0] == "s"
|
|
|
|
def test_base_pyproject_read_fails_closed(self, clean_ci_env, tmp_path):
|
|
mp = clean_ci_env
|
|
mp.setenv("GITHUB_EVENT_NAME", "pull_request")
|
|
mp.setenv("GITHUB_EVENT_PATH", str(self._event(tmp_path)))
|
|
mp.setattr(mod, "_resolve_merge_base", lambda b, h: "MB")
|
|
|
|
def fake_git(*a):
|
|
if a[0] == "log":
|
|
return (0, "s\x00n\x00e@x.test\x00n\x00e@x.test\x00b\x1e")
|
|
if a[0] == "show":
|
|
return (1, "") # base pyproject read fails
|
|
return (0, "")
|
|
|
|
mp.setattr(mod, "_git", fake_git)
|
|
with pytest.raises(RuntimeError):
|
|
mod._pr_context()
|
|
|
|
def test_non_pr_event_returns_none(self, clean_ci_env):
|
|
# No event + not a PR event -> local mode (None), not fail-closed.
|
|
assert mod._pr_context() is None
|
|
|
|
def test_pr_event_without_payload_fails_closed(self, clean_ci_env):
|
|
clean_ci_env.setenv("GITHUB_EVENT_NAME", "pull_request")
|
|
with pytest.raises(RuntimeError):
|
|
mod._pr_context()
|
|
|
|
|
|
class TestMain:
|
|
def test_fails_closed_on_unresolvable_range(self, monkeypatch, capsys):
|
|
def boom():
|
|
raise RuntimeError("could not resolve the PR commit range")
|
|
|
|
monkeypatch.setattr(mod, "_pr_context", boom)
|
|
assert mod.main() == 1
|
|
assert "failing closed" in capsys.readouterr().err
|
|
|
|
def test_violation_in_pr_range_returns_1(self, monkeypatch, capsys):
|
|
rec = (
|
|
"deadbeef0",
|
|
"LearningCircuit",
|
|
"bad@nope.test",
|
|
"C",
|
|
"c@x.test",
|
|
"",
|
|
)
|
|
monkeypatch.setattr(mod, "_pr_context", lambda: ([rec], PYPROJECT))
|
|
assert mod.main() == 1
|
|
err = capsys.readouterr().err
|
|
assert "LearningCircuit" in err
|
|
assert "bad@nope.test" not in err # offending email never printed
|
|
|
|
def test_clean_pr_range_returns_0(self, monkeypatch):
|
|
good = "185559241+learningcircuit@users.noreply.github.com"
|
|
rec = (
|
|
"deadbeef0",
|
|
"LearningCircuit",
|
|
good,
|
|
"LearningCircuit",
|
|
good,
|
|
"",
|
|
)
|
|
monkeypatch.setattr(mod, "_pr_context", lambda: ([rec], PYPROJECT))
|
|
assert mod.main() == 0
|
|
|
|
def test_no_declared_authors_returns_0(self, monkeypatch):
|
|
rec = (
|
|
"deadbeef0",
|
|
"LearningCircuit",
|
|
"bad@nope.test",
|
|
"C",
|
|
"c@x.test",
|
|
"",
|
|
)
|
|
monkeypatch.setattr(
|
|
mod, "_pr_context", lambda: ([rec], '[project]\nname = "x"\n')
|
|
)
|
|
assert mod.main() == 0
|
|
|
|
|
|
class TestPendingRecords:
|
|
def test_parses_git_idents(self, monkeypatch):
|
|
ident = "LearningCircuit <bad@nope.test> 1700000000 +0000"
|
|
monkeypatch.setattr(mod, "_git", lambda *a: (0, ident))
|
|
recs = mod._pending_records()
|
|
assert recs[0][1] == "LearningCircuit"
|
|
assert recs[0][2] == "bad@nope.test"
|
|
|
|
|
|
class TestAnchorBoundary:
|
|
def test_authors_prefixed_key_not_hijacked(self):
|
|
text = (
|
|
'authors_extra = [{name = "E", email = "evil@x.test"}]\n'
|
|
'authors = [\n {name = "A", email = "a@b.test"},\n]\n'
|
|
)
|
|
assert mod.parse_identities(text) == {"a": {"a@b.test"}}
|
|
|
|
|
|
class TestAllowlistFailClosed:
|
|
"""The guard must not silently disable itself if the authors block changes."""
|
|
|
|
def test_real_repo_pyproject_still_parses_to_nonempty(self):
|
|
# If a future reformat of the real pyproject.toml breaks parsing, this
|
|
# trips here (and the CI path fails closed) instead of silently passing.
|
|
root = mod.Path(__file__).resolve().parents[2]
|
|
declared = mod.parse_identities(
|
|
(root / "pyproject.toml").read_text(encoding="utf-8")
|
|
)
|
|
assert declared, (
|
|
"pyproject.toml authors no longer parse -> guard disabled"
|
|
)
|
|
assert "learningcircuit" in declared
|
|
assert any(
|
|
e.endswith("@users.noreply.github.com")
|
|
for e in declared["learningcircuit"]
|
|
)
|
|
|
|
def test_ci_fails_closed_when_authors_declared_but_unparsed(
|
|
self, monkeypatch
|
|
):
|
|
# array-of-tables form parse_identities does not understand: authors ARE
|
|
# declared but none parse -> fail closed rather than return 0.
|
|
unparsable = '[[project.authors]]\nname = "X"\nemail = "x@y.test"\n'
|
|
monkeypatch.setattr(mod, "_pr_context", lambda: ([], unparsable))
|
|
assert mod.main() == 1
|
|
|
|
def test_ci_passes_when_no_authors_block_at_all(self, monkeypatch):
|
|
# genuinely no authors declared -> not enforced -> pass (no over-block).
|
|
monkeypatch.setattr(
|
|
mod, "_pr_context", lambda: ([], '[project]\nname = "x"\n')
|
|
)
|
|
assert mod.main() == 0
|