diff --git a/tests/test_publish_wiki.py b/tests/test_publish_wiki.py index 35249cd..3b63d19 100644 --- a/tests/test_publish_wiki.py +++ b/tests/test_publish_wiki.py @@ -191,6 +191,57 @@ class TestMain: assert gitea.wiki == {"Home": b64("keep me")} +class TestDescribeToken: + def test_recognises_a_gitea_token_shape(self): + description = publish_wiki.describe_token("a1b2c3d4e5" * 4) + assert "40 characters" in description + assert "looks like a Gitea token" in description + + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("*******", "only mask characters"), + ("TOKEN_FOR_GITEA_WIKI=" + "a" * 40, "contains '='"), + ('"' + "a" * 40 + '"', "quote character"), + ("abc def", "whitespace"), + ], + ) + def test_names_the_likely_mistake_without_echoing_the_token(self, token, expected): + description = publish_wiki.describe_token(token) + assert expected in description + assert token not in description + + +class TestRejectedToken: + def test_rejection_adds_the_token_shape_but_not_the_token(self, monkeypatch, environment): + monkeypatch.setenv("WIKI_CREDENTIALS", "*******") + + def reject(url, method="GET", headers=None, body=None): + raise publish_wiki.WorkflowError("credentials", "Gitea rejected the token.") + + monkeypatch.setattr(publish_wiki, "request_json", reject) + + with pytest.raises(publish_wiki.WorkflowError) as caught: + publish_wiki.main() + + message = str(caught.value) + assert caught.value.category == "credentials" + assert "Token shape: 7 characters" in message + assert "only mask characters" in message + assert "*******" not in message.split("Token shape:")[0] + + def test_other_categories_are_not_changed(self, monkeypatch, environment): + def forbid(url, method="GET", headers=None, body=None): + raise publish_wiki.WorkflowError("permissions", "Forbidden.") + + monkeypatch.setattr(publish_wiki, "request_json", forbid) + + with pytest.raises(publish_wiki.WorkflowError) as caught: + publish_wiki.main() + + assert str(caught.value) == "[permissions] Forbidden." + + class TestRequestJson: @staticmethod def raise_http_error(monkeypatch, code, reason): diff --git a/tools/publish_wiki.py b/tools/publish_wiki.py index c8efecc..767370d 100644 --- a/tools/publish_wiki.py +++ b/tools/publish_wiki.py @@ -16,6 +16,7 @@ Exit codes: 0 published or nothing to do, 1 failed (message names a category). import base64 import json import os +import re import sys import urllib.error import urllib.parse @@ -43,6 +44,7 @@ class WorkflowError(RuntimeError): def __init__(self, category: str, message: str) -> None: super().__init__(f"[{category}] {message}") self.category = category + self.detail = message def request_json( @@ -110,6 +112,28 @@ def parse_gitea_token(raw_credentials: str | None) -> str: return token.strip() +def describe_token(token: str) -> str: + """Describe the shape of a token without revealing any of its characters. + + Used when Gitea rejects the token, so a wrongly stored secret (the masked + text, ``NAME=value``, quotes) can be recognised from the log. + """ + facts = [f"{len(token)} characters"] + if re.fullmatch(r"[0-9a-f]{40}", token): + facts.append("looks like a Gitea token (40 hexadecimal characters)") + return ", ".join(facts) + facts.append("a Gitea token has 40 hexadecimal characters") + if set(token) <= {"*", "x", "X", "•"}: + facts.append("only mask characters: the masked text was stored") + if "=" in token: + facts.append("contains '=': the secret name may be part of the value") + if re.search(r"[\"'`]", token): + facts.append("contains a quote character") + if re.search(r"\s", token): + facts.append("contains whitespace") + return ", ".join(facts) + + def split_repository(full_name: str | None) -> tuple[str, str]: """Split ``owner/repo`` into its two parts.""" owner, separator, repo = (full_name or "").partition("/") @@ -218,15 +242,22 @@ def main() -> int: "Content-Type": "application/json", } - repository = request_json(repository_url, headers=headers) - if isinstance(repository, dict) and repository.get("has_wiki") is False: - raise WorkflowError( - "configuration", - f"The wiki is not enabled on {owner}/{repo}. Enable it in the repository settings.", - ) - print("Preflight passed: Gitea accepted the token and the wiki is enabled.") + try: + repository = request_json(repository_url, headers=headers) + if isinstance(repository, dict) and repository.get("has_wiki") is False: + raise WorkflowError( + "configuration", + f"The wiki is not enabled on {owner}/{repo}. Enable it in the repository settings.", + ) + print("Preflight passed: Gitea accepted the token and the wiki is enabled.") - result = publish(pages, f"{repository_url}/wiki", headers, request_json) + result = publish(pages, f"{repository_url}/wiki", headers, request_json) + except WorkflowError as error: + if error.category == "credentials": + raise WorkflowError( + "credentials", f"{error.detail} Token shape: {describe_token(token)}." + ) from None + raise print( f"Published {len(pages)} page(s) to the wiki of {owner}/{repo}: " f"{len(result['created'])} created, {len(result['updated'])} updated, "