Reuse TOKEN_FOR_GITEA for the wiki when no wiki secret exists #49

Merged
Tirsvad merged 2 commits from mil-005-wiki-token-fallback into main 2026-10-10 04:05:12 +02:00
2 changed files with 90 additions and 8 deletions
Showing only changes of commit 0135b2763c - Show all commits
+51
View File
@@ -191,6 +191,57 @@ class TestMain:
assert gitea.wiki == {"Home": b64("keep me")} 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: class TestRequestJson:
@staticmethod @staticmethod
def raise_http_error(monkeypatch, code, reason): def raise_http_error(monkeypatch, code, reason):
+31
View File
@@ -16,6 +16,7 @@ Exit codes: 0 published or nothing to do, 1 failed (message names a category).
import base64 import base64
import json import json
import os import os
import re
import sys import sys
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@@ -43,6 +44,7 @@ class WorkflowError(RuntimeError):
def __init__(self, category: str, message: str) -> None: def __init__(self, category: str, message: str) -> None:
super().__init__(f"[{category}] {message}") super().__init__(f"[{category}] {message}")
self.category = category self.category = category
self.detail = message
def request_json( def request_json(
@@ -110,6 +112,28 @@ def parse_gitea_token(raw_credentials: str | None) -> str:
return token.strip() 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]: def split_repository(full_name: str | None) -> tuple[str, str]:
"""Split ``owner/repo`` into its two parts.""" """Split ``owner/repo`` into its two parts."""
owner, separator, repo = (full_name or "").partition("/") owner, separator, repo = (full_name or "").partition("/")
@@ -218,6 +242,7 @@ def main() -> int:
"Content-Type": "application/json", "Content-Type": "application/json",
} }
try:
repository = request_json(repository_url, headers=headers) repository = request_json(repository_url, headers=headers)
if isinstance(repository, dict) and repository.get("has_wiki") is False: if isinstance(repository, dict) and repository.get("has_wiki") is False:
raise WorkflowError( raise WorkflowError(
@@ -227,6 +252,12 @@ def main() -> int:
print("Preflight passed: Gitea accepted the token and the wiki is enabled.") 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( print(
f"Published {len(pages)} page(s) to the wiki of {owner}/{repo}: " f"Published {len(pages)} page(s) to the wiki of {owner}/{repo}: "
f"{len(result['created'])} created, {len(result['updated'])} updated, " f"{len(result['created'])} created, {len(result['updated'])} updated, "