Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab122d2073 | ||
|
|
a1561e2c6c | ||
|
|
7dcb9c4dfc | ||
|
|
16b9060d4c |
@@ -29,6 +29,25 @@ jobs:
|
||||
import urllib.request
|
||||
|
||||
|
||||
class WorkflowError(RuntimeError):
|
||||
"""A failure with a category.
|
||||
|
||||
configuration: a secret or repository setting is missing or malformed.
|
||||
credentials: a service rejected the token (HTTP 401).
|
||||
permissions: the token is valid but not allowed (HTTP 403).
|
||||
api: any other API or network failure.
|
||||
"""
|
||||
|
||||
def __init__(self, category, message):
|
||||
super().__init__(f"[{category}] {message}")
|
||||
self.category = category
|
||||
|
||||
|
||||
def service_name(url):
|
||||
hostname = urllib.parse.urlsplit(url).hostname
|
||||
return "GitHub" if hostname == "api.github.com" else "Gitea"
|
||||
|
||||
|
||||
def request_json(url, method="GET", headers=None, body=None):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
@@ -36,37 +55,64 @@ jobs:
|
||||
headers=headers or {},
|
||||
method=method,
|
||||
)
|
||||
service = service_name(url)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
content = response.read()
|
||||
return json.loads(content) if content else None
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(
|
||||
f"API request failed with HTTP {error.code} ({error.reason})"
|
||||
detail = (
|
||||
f"{service} API request failed with HTTP "
|
||||
f"{error.code} ({error.reason})"
|
||||
)
|
||||
if error.code == 401:
|
||||
raise WorkflowError(
|
||||
"credentials", f"{detail}. The token was rejected."
|
||||
) from None
|
||||
if error.code == 403:
|
||||
raise WorkflowError(
|
||||
"permissions",
|
||||
f"{detail}. The token lacks a required permission.",
|
||||
) from None
|
||||
if error.code == 404:
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
f"{detail}. The repository was not found or the "
|
||||
"token cannot see it.",
|
||||
) from None
|
||||
raise WorkflowError("api", detail) from None
|
||||
except (urllib.error.URLError, TimeoutError) as error:
|
||||
raise WorkflowError(
|
||||
"api", f"{service} API is unreachable ({error})."
|
||||
) from None
|
||||
|
||||
|
||||
def parse_github_token(raw_credentials):
|
||||
raw_credentials = (raw_credentials or "").strip()
|
||||
if not raw_credentials:
|
||||
raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.")
|
||||
raise WorkflowError(
|
||||
"configuration", "CREDENTIALS_FOR_GITHUB is missing or empty."
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = json.loads(raw_credentials)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(
|
||||
"CREDENTIALS_FOR_GITHUB must contain valid JSON."
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"CREDENTIALS_FOR_GITHUB must contain valid JSON.",
|
||||
) from None
|
||||
|
||||
if not isinstance(credentials, dict):
|
||||
raise RuntimeError(
|
||||
"CREDENTIALS_FOR_GITHUB must be a JSON object."
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"CREDENTIALS_FOR_GITHUB must be a JSON object.",
|
||||
)
|
||||
|
||||
token = credentials.get("GITHUB_PAT")
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise RuntimeError(
|
||||
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT."
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.",
|
||||
)
|
||||
return token.strip()
|
||||
|
||||
@@ -74,7 +120,9 @@ jobs:
|
||||
def parse_gitea_token(raw_credentials):
|
||||
raw_credentials = (raw_credentials or "").strip()
|
||||
if not raw_credentials:
|
||||
raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.")
|
||||
raise WorkflowError(
|
||||
"configuration", "TOKEN_FOR_GITEA is missing or empty."
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = json.loads(raw_credentials)
|
||||
@@ -89,8 +137,9 @@ jobs:
|
||||
token = None
|
||||
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise RuntimeError(
|
||||
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN."
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.",
|
||||
)
|
||||
return token.strip()
|
||||
|
||||
@@ -98,7 +147,10 @@ jobs:
|
||||
def split_repository(full_name):
|
||||
owner, separator, repo = (full_name or "").partition("/")
|
||||
if not separator or not owner or not repo:
|
||||
raise RuntimeError("Could not determine the Gitea source repository.")
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"Could not determine the Gitea source repository.",
|
||||
)
|
||||
return owner, repo
|
||||
|
||||
|
||||
@@ -117,16 +169,18 @@ jobs:
|
||||
mirror_path = mirror_path.removesuffix(".git").strip("/")
|
||||
path_parts = mirror_path.split("/")
|
||||
if len(path_parts) != 2 or not all(path_parts):
|
||||
raise RuntimeError(
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"Could not determine the GitHub owner and repository "
|
||||
"from a configured push mirror."
|
||||
"from a configured push mirror.",
|
||||
)
|
||||
targets.append(tuple(path_parts))
|
||||
|
||||
if len(targets) != 1:
|
||||
raise RuntimeError(
|
||||
raise WorkflowError(
|
||||
"configuration",
|
||||
"Expected exactly one GitHub push mirror for this repository; "
|
||||
f"found {len(targets)}."
|
||||
f"found {len(targets)}.",
|
||||
)
|
||||
return targets[0]
|
||||
|
||||
@@ -169,6 +223,16 @@ jobs:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
github_repository = request_json(github_api_url, headers=github_headers)
|
||||
permissions = (github_repository or {}).get("permissions")
|
||||
if isinstance(permissions, dict) and not permissions.get("admin"):
|
||||
raise WorkflowError(
|
||||
"permissions",
|
||||
f"The GitHub token cannot administer {github_owner}/{github_repo}; "
|
||||
"editing the description and topics needs administration access.",
|
||||
)
|
||||
print("Preflight passed: Gitea and GitHub accepted the credentials.")
|
||||
|
||||
request_json(
|
||||
github_api_url,
|
||||
method="PATCH",
|
||||
|
||||
@@ -63,8 +63,8 @@ Only the first row is implemented. The rest is planned and tracked in the [Proje
|
||||
| Capability | Status |
|
||||
| --- | --- |
|
||||
| Synchronize repository metadata (Gitea to GitHub mirror) | Implemented in `.gitea/scoped_workflows/`, with offline tests and a validation workflow |
|
||||
| Configure and validate workflow credentials | Partial: secrets are checked for format only |
|
||||
| Onboard a repository, manage runners, diagnose failures | Planned (MIL-002) |
|
||||
| Configure and validate workflow credentials | Implemented for the sync workflow: preflight checks both tokens before any change ([guide](guides/credentials.md)) |
|
||||
| Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](guides/onboarding.md), [troubleshooting](guides/troubleshooting.md)); scoped delivery to other repositories not yet verified; brief use cases open (MIL-002) |
|
||||
| Shared quality checks | Planned (MIL-003) |
|
||||
| Versioned release and publishing | Planned (MIL-004) |
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Recorded verification results for task 2 | [7dcb9c4] |
|
||||
|
||||
---
|
||||
|
||||
@@ -63,7 +63,7 @@ a credential validation step in the workflow, and categorized failure messages.
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Write brief use cases for the P1 candidates | Turn the DevOps use cases in the Project Plan appendix into use case documents under `docs/uc-NNN/`, after a use case diagram and user stories exist. | No | |
|
||||
| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. | No | |
|
||||
| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. Result: Gitea 1.27.3 runs the scoped sync workflow in this repository (run 16); delivery to other repositories is not yet verified and is recorded in `guides/onboarding.md`. | No | |
|
||||
| 3 | Write the repository onboarding steps | Describe how to consume the workflow (scoped or copied), which secrets to set and how to test the first run. | No | |
|
||||
| 4 | Document least-privilege tokens | Determine the minimum Gitea token scope and GitHub fine-grained token permission that the sync needs, and document them. | No | |
|
||||
| 5 | Add a credential preflight step | Before the sync, check that both tokens authenticate and have the needed access, and report the result without printing any secret. | No | |
|
||||
@@ -74,3 +74,4 @@ a credential validation step in the workflow, and categorized failure messages.
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
|
||||
[7dcb9c4]: https://git.tirsystem.com/TirSystem/github-action/commit/7dcb9c4dfce712572aced281d0dfdfa435283646
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
|
||||
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
|
||||
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
|
||||
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Workflow credentials
|
||||
|
||||
The sync workflow uses two secrets. Give each the least privilege that works,
|
||||
store it only as a Gitea secret, and rotate it when a person with access
|
||||
leaves.
|
||||
|
||||
| Secret | Content | Used for |
|
||||
| --- | --- | --- |
|
||||
| `TOKEN_FOR_GITEA` | A bare token, a JSON string, or JSON with `GITEA_TOKEN` | Reading the repository (description, topics) and its push mirrors |
|
||||
| `CREDENTIALS_FOR_GITHUB` | JSON object with a non-empty `GITHUB_PAT` | Changing the description and topics of the GitHub mirror |
|
||||
|
||||
## Minimum permissions
|
||||
|
||||
These come from the Gitea and GitHub documentation and the API calls the
|
||||
workflow makes. They have **not been confirmed with a minimal token on this
|
||||
instance yet**; S05 should confirm them when the tokens are created.
|
||||
|
||||
**Gitea token** (a personal access token of a user, not a deploy key; deploy
|
||||
keys do not work for the REST API):
|
||||
|
||||
- scope `read:repository`
|
||||
- the user must be allowed to read the repository's push mirrors, which
|
||||
Gitea limits to repository administrators
|
||||
|
||||
Calls made: `GET /repos/{owner}/{repo}` and `GET /repos/{owner}/{repo}/push_mirrors`.
|
||||
|
||||
**GitHub token** (fine-grained personal access token):
|
||||
|
||||
- Resource owner: the owner of the mirror repository
|
||||
- Repository access: only the mirror repository
|
||||
- Repository permission: **Administration: Read and write** (no other permission)
|
||||
- Set an expiry date
|
||||
|
||||
Calls made: `GET /repos/{owner}/{repo}`, `PATCH /repos/{owner}/{repo}` (description)
|
||||
and `PUT /repos/{owner}/{repo}/topics`.
|
||||
|
||||
Format of `CREDENTIALS_FOR_GITHUB`:
|
||||
|
||||
```json
|
||||
{"GITHUB_PAT": "<token>"}
|
||||
```
|
||||
|
||||
## What the workflow checks before it changes anything
|
||||
|
||||
The workflow runs a preflight and writes nothing to GitHub unless it passes:
|
||||
|
||||
1. Both secrets exist and have the expected shape.
|
||||
2. Gitea accepts the token and the repository is visible with it.
|
||||
3. Exactly one GitHub push mirror is configured.
|
||||
4. GitHub accepts the token for the mirror repository **and** the token
|
||||
reports administration access to it.
|
||||
|
||||
On success the log says `Preflight passed: Gitea and GitHub accepted the
|
||||
credentials.` Failures start with a category such as `[credentials]` or
|
||||
`[permissions]`; see [troubleshooting.md](troubleshooting.md). No secret value
|
||||
is ever printed.
|
||||
|
||||
## Rotating a token
|
||||
|
||||
1. Create the new token with the permissions above.
|
||||
2. Replace the secret value in the repository or organization settings.
|
||||
3. Run the workflow manually and wait for `Preflight passed`.
|
||||
4. Revoke the old token.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Onboarding a repository
|
||||
|
||||
How a DevOps professional makes a TirSystem repository use the shared
|
||||
**Sync GitHub mirror metadata** workflow, and which runners it needs. Only
|
||||
this workflow exists today; the quality-check and release workflows are
|
||||
planned (see the [Project Plan](../docs/project-plan.md)).
|
||||
|
||||
## What has been verified
|
||||
|
||||
| Item | Status |
|
||||
| --- | --- |
|
||||
| Gitea version | 1.27.3 |
|
||||
| `.gitea/scoped_workflows/sync-github-metadata.yml` runs in this repository | Verified: run 16, `workflow_dispatch`, succeeded |
|
||||
| The same workflow applies to other repositories automatically | **Not verified.** Confirm how scoped workflows are delivered on the instance (admin or organization settings) before relying on it. |
|
||||
|
||||
Until delivery is verified, onboard a repository by copying the workflow (below).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Check the prerequisites** in the repository: Actions are enabled, and a
|
||||
runner with the label `ubuntu-latest` and `python3` is online (see
|
||||
[Runners](#runners)).
|
||||
2. **Configure exactly one GitHub push mirror** in the repository settings
|
||||
(Settings, Repository, Mirror settings, Push mirror). The workflow fails
|
||||
when there are zero or several GitHub mirrors.
|
||||
3. **Add the two secrets** in the repository (or organization) settings,
|
||||
using the scopes in [credentials.md](credentials.md):
|
||||
- `TOKEN_FOR_GITEA`
|
||||
- `CREDENTIALS_FOR_GITHUB` (JSON with `GITHUB_PAT`)
|
||||
4. **Deliver the workflow:**
|
||||
- *If scoped workflows apply to the repository:* nothing to copy.
|
||||
- *Otherwise:* copy `.gitea/scoped_workflows/sync-github-metadata.yml`
|
||||
from this repository to `.gitea/workflows/` in the target repository.
|
||||
Pin the copy to a reviewed version of this repository and re-copy
|
||||
when it changes.
|
||||
5. **Set a description and topics** on the Gitea repository, so there is
|
||||
something to sync.
|
||||
6. **Run it once manually:** Actions, **Sync GitHub mirror metadata**,
|
||||
Run workflow. The log must end with `Synced description and topics to
|
||||
<owner>/<repo>.` and the GitHub repository must show the same description
|
||||
and topics.
|
||||
7. If it fails, use [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Runners
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Label the workflow uses | `ubuntu-latest` |
|
||||
| Labels offered by the runner on this instance | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` |
|
||||
| Tools the workflow needs | `python3` (standard library only), outbound HTTPS to the Gitea API and `api.github.com` |
|
||||
| Needed for `actions/checkout` (used by the validation workflow) | `git` and Node.js, normally in the runner image |
|
||||
|
||||
**Check that a suitable runner is online** (needs permission to view runners):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
https://git.tirsystem.com/api/v1/repos/<owner>/<repo>/actions/runners
|
||||
```
|
||||
|
||||
For runners shared by the whole instance, an administrator uses
|
||||
`/api/v1/admin/actions/runners`. At the time of writing the repository and
|
||||
organization lists are empty and one instance-level runner (`36cd20104923`)
|
||||
is `online`.
|
||||
|
||||
**Offline or incompatible runner**
|
||||
|
||||
| Symptom | Likely cause | Action |
|
||||
| --- | --- | --- |
|
||||
| Run stays `waiting` | No online runner has the label in `runs-on` | Start the runner, or register one with the label |
|
||||
| Job fails at the start with a missing command | Runner image lacks `python3`, `git` or Node.js | Use an image that has them |
|
||||
| Job cannot reach the API | Runner has no outbound HTTPS | Fix runner network or proxy settings |
|
||||
@@ -0,0 +1,60 @@
|
||||
# Diagnosing a failed run
|
||||
|
||||
Open the run in Gitea (Actions) and read the last lines of the failing step.
|
||||
Every failure from the sync script is written as
|
||||
|
||||
```
|
||||
ERROR: [<category>] <message>
|
||||
```
|
||||
|
||||
The category tells you where to look. A failure outside the script (the job
|
||||
never starts, or stops before the step) is a **runner** problem.
|
||||
|
||||
| Category | Meaning | First thing to check |
|
||||
| --- | --- | --- |
|
||||
| `configuration` | A secret or a repository setting is missing or malformed | Secret names and format, push mirror setup |
|
||||
| `credentials` | A service rejected the token (HTTP 401) | Token expired, revoked or mistyped |
|
||||
| `permissions` | The token is valid but not allowed (HTTP 403, or no GitHub administration access) | Token scopes and repository access |
|
||||
| `api` | Another HTTP error, or the service was unreachable | Service status, runner network |
|
||||
| runner | No `ERROR:` line; the run waits, or the job fails before the script | Runner online, label, tools |
|
||||
|
||||
## Messages and fixes
|
||||
|
||||
| Message (start) | Category | Cause and fix |
|
||||
| --- | --- | --- |
|
||||
| `CREDENTIALS_FOR_GITHUB is missing or empty.` | configuration | Add the secret |
|
||||
| `CREDENTIALS_FOR_GITHUB must contain valid JSON.` | configuration | The value must be JSON such as `{"GITHUB_PAT": "..."}` |
|
||||
| `CREDENTIALS_FOR_GITHUB must be a JSON object.` | configuration | Use an object, not a list or string |
|
||||
| `CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.` | configuration | Add the `GITHUB_PAT` key with a token |
|
||||
| `TOKEN_FOR_GITEA is missing or empty.` | configuration | Add the secret |
|
||||
| `TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.` | configuration | The JSON form needs a `GITEA_TOKEN` key |
|
||||
| `Could not determine the Gitea source repository.` | configuration | The run has no repository name; report as a defect |
|
||||
| `Expected exactly one GitHub push mirror ... found 0` | configuration | Add a GitHub push mirror in the repository settings |
|
||||
| `Expected exactly one GitHub push mirror ... found N` | configuration | Keep one GitHub push mirror |
|
||||
| `Could not determine the GitHub owner and repository ...` | configuration | The mirror address must look like `https://github.com/<owner>/<repo>.git` |
|
||||
| `Gitea API request failed with HTTP 401` | credentials | Create a new Gitea token and update `TOKEN_FOR_GITEA` |
|
||||
| `GitHub API request failed with HTTP 401` | credentials | Create a new GitHub token and update `CREDENTIALS_FOR_GITHUB` |
|
||||
| `... HTTP 403 ...` (Gitea) | permissions | Token needs `read:repository`, and its user needs administrator access to the repository |
|
||||
| `... HTTP 403 ...` (GitHub) | permissions | Token needs Administration read and write on the mirror repository |
|
||||
| `The GitHub token cannot administer <repo>` | permissions | Same as above; the token can read the repository but not edit it |
|
||||
| `... HTTP 404 ...` | configuration | Repository not found, or the token cannot see it; check the mirror address and token repository access |
|
||||
| `... HTTP 5xx ...` | api | Service problem; re-run later |
|
||||
| `... API is unreachable ...` | api | Network, DNS or proxy problem on the runner |
|
||||
|
||||
## Not an error
|
||||
|
||||
`WARNING: Gitea returned an invalid push mirror list; GitHub metadata was not
|
||||
synced.` The run succeeds but nothing was synced. Check that the Gitea
|
||||
version supports the push mirror API and re-run.
|
||||
|
||||
## Runner problems
|
||||
|
||||
See the [Runners](onboarding.md#runners) section. In short: if the run stays
|
||||
`waiting`, no online runner has the label; if the job fails before the
|
||||
script, the runner image is missing a tool.
|
||||
|
||||
## Re-running
|
||||
|
||||
Use **Run workflow** (`workflow_dispatch`) after fixing the cause. The sync is
|
||||
safe to repeat: it overwrites the GitHub description and topics with the
|
||||
Gitea values.
|
||||
@@ -119,23 +119,68 @@ class TestFindGithubTargets:
|
||||
|
||||
|
||||
class TestRequestJson:
|
||||
def test_maps_http_error_without_leaking_the_response_body(self, sync, monkeypatch):
|
||||
@staticmethod
|
||||
def raise_http_error(sync, monkeypatch, code, reason):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise urllib.error.HTTPError(
|
||||
request.full_url, 403, "Forbidden", {}, io.BytesIO(b"secret detail")
|
||||
request.full_url, code, reason, {}, io.BytesIO(b"secret detail")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "reason", "category"),
|
||||
[
|
||||
(401, "Unauthorized", "credentials"),
|
||||
(403, "Forbidden", "permissions"),
|
||||
(404, "Not Found", "configuration"),
|
||||
(500, "Server Error", "api"),
|
||||
],
|
||||
)
|
||||
def test_categorizes_http_errors(self, sync, monkeypatch, code, reason, category):
|
||||
self.raise_http_error(sync, monkeypatch, code, reason)
|
||||
with pytest.raises(sync["WorkflowError"]) as caught:
|
||||
sync["request_json"]("https://example.test/x")
|
||||
assert caught.value.category == category
|
||||
assert str(caught.value).startswith(f"[{category}] Gitea API request failed")
|
||||
assert f"HTTP {code} ({reason})" in str(caught.value)
|
||||
|
||||
def test_does_not_leak_the_response_body(self, sync, monkeypatch):
|
||||
self.raise_http_error(sync, monkeypatch, 403, "Forbidden")
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
sync["request_json"]("https://example.test/x")
|
||||
assert str(caught.value) == "API request failed with HTTP 403 (Forbidden)"
|
||||
assert "secret detail" not in str(caught.value)
|
||||
|
||||
def test_names_github_for_github_urls(self, sync, monkeypatch):
|
||||
self.raise_http_error(sync, monkeypatch, 401, "Unauthorized")
|
||||
with pytest.raises(RuntimeError, match="GitHub API request failed"):
|
||||
sync["request_json"]("https://api.github.com/repos/Org/repo")
|
||||
|
||||
def test_reports_unreachable_service_as_api_failure(self, sync, monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise urllib.error.URLError("name resolution failed")
|
||||
|
||||
monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen)
|
||||
with pytest.raises(sync["WorkflowError"]) as caught:
|
||||
sync["request_json"]("https://example.test/x")
|
||||
assert caught.value.category == "api"
|
||||
assert "unreachable" in str(caught.value)
|
||||
|
||||
|
||||
class TestWorkflowError:
|
||||
def test_configuration_errors_carry_their_category(self, sync):
|
||||
with pytest.raises(sync["WorkflowError"]) as caught:
|
||||
sync["parse_github_token"]("")
|
||||
assert caught.value.category == "configuration"
|
||||
assert str(caught.value).startswith("[configuration] ")
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Records calls and answers like the Gitea and GitHub REST APIs."""
|
||||
|
||||
def __init__(self, mirrors, source=None):
|
||||
def __init__(self, mirrors, source=None, github=None):
|
||||
self.mirrors = mirrors
|
||||
self.github = github if github is not None else {"permissions": {"admin": True}}
|
||||
self.source = source or {"description": "Desc", "topics": ["a", "b"]}
|
||||
self.calls = []
|
||||
|
||||
@@ -145,6 +190,8 @@ class FakeApi:
|
||||
return self.mirrors
|
||||
if url.startswith("https://git.example.test"):
|
||||
return self.source
|
||||
if method == "GET" and url.startswith("https://api.github.com"):
|
||||
return self.github
|
||||
return None
|
||||
|
||||
|
||||
@@ -169,13 +216,14 @@ class TestMain:
|
||||
assert requests == [
|
||||
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo"),
|
||||
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo/push_mirrors"),
|
||||
("GET", "https://api.github.com/repos/Org/repo"),
|
||||
("PATCH", "https://api.github.com/repos/Org/repo"),
|
||||
("PUT", "https://api.github.com/repos/Org/repo/topics"),
|
||||
]
|
||||
assert api.calls[0][2]["Authorization"] == "token gitea-token"
|
||||
assert api.calls[2][2]["Authorization"] == "Bearer gh-token"
|
||||
assert api.calls[2][3] == {"description": "Desc"}
|
||||
assert api.calls[3][3] == {"names": ["a", "b"]}
|
||||
assert api.calls[3][3] == {"description": "Desc"}
|
||||
assert api.calls[4][3] == {"names": ["a", "b"]}
|
||||
assert "Synced description and topics to Org/repo." in capsys.readouterr().out
|
||||
|
||||
def test_sends_empty_values_when_gitea_has_none(self, sync, environment):
|
||||
@@ -187,8 +235,8 @@ class TestMain:
|
||||
|
||||
sync["main"]()
|
||||
|
||||
assert api.calls[2][3] == {"description": ""}
|
||||
assert api.calls[3][3] == {"names": []}
|
||||
assert api.calls[3][3] == {"description": ""}
|
||||
assert api.calls[4][3] == {"names": []}
|
||||
|
||||
def test_skips_with_a_warning_when_mirror_list_is_invalid(
|
||||
self, sync, environment, capsys
|
||||
@@ -211,3 +259,28 @@ class TestMain:
|
||||
with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"):
|
||||
sync["main"]()
|
||||
assert api.calls == []
|
||||
|
||||
def test_stops_before_any_change_when_github_token_cannot_administer(
|
||||
self, sync, environment
|
||||
):
|
||||
api = FakeApi(
|
||||
[{"remote_address": "git@github.com:Org/repo.git"}],
|
||||
github={"permissions": {"admin": False, "push": True}},
|
||||
)
|
||||
sync["request_json"] = api
|
||||
|
||||
with pytest.raises(sync["WorkflowError"]) as caught:
|
||||
sync["main"]()
|
||||
|
||||
assert caught.value.category == "permissions"
|
||||
assert all(call[0] == "GET" for call in api.calls)
|
||||
|
||||
def test_does_not_print_any_secret(self, sync, environment, capsys):
|
||||
api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}])
|
||||
sync["request_json"] = api
|
||||
|
||||
sync["main"]()
|
||||
|
||||
output = capsys.readouterr()
|
||||
assert "gh-token" not in output.out + output.err
|
||||
assert "gitea-token" not in output.out + output.err
|
||||
|
||||
Reference in New Issue
Block a user