arnaucube
arnaucube/gitviewPublic Self-hosted repository
gitview/scripts/tests/test_mirror_github.py
scripts/tests/test_mirror_github.pyRaw
  1"""Exercise the mirror script against local Git repositories and a stub GitHub API."""
  2import json
  3import os
  4from pathlib import Path
  5import subprocess
  6import tempfile
  7import unittest
  8
  9SCRIPT = Path(__file__).resolve().parents[1] / "mirror-github.sh"
 10
 11
 12class MirrorGitHubTests(unittest.TestCase):
 13    def setUp(self):
 14        self.temp = tempfile.TemporaryDirectory()
 15        self.addCleanup(self.temp.cleanup)
 16        self.root = Path(self.temp.name)
 17        self.sources = self.root / "sources"
 18        self.sources.mkdir()
 19        self.destination = self.root / "served repos"
 20        self.fixtures = self.root / "pages"
 21        self.fixtures.mkdir()
 22        tools = self.root / "tools"
 23        tools.mkdir()
 24        # Stub only HTTP discovery; every clone/fetch uses real Git with local URL rewriting.
 25        curl = tools / "curl"
 26        curl.write_text('''#!/usr/bin/env python3
 27import json, os, sys
 28from pathlib import Path
 29from urllib.parse import parse_qs, urlparse
 30if os.environ.get("TEST_API_FAILURE"):
 31    sys.exit(22)
 32args = sys.argv[1:]
 33assert not any("test-token" in arg for arg in args)
 34headers = sys.stdin.read()
 35if os.environ.get("GITHUB_TOKEN"):
 36    assert "Authorization: Bearer test-token" in headers
 37url = urlparse(args[-1])
 38assert url.path == "/users/testuser/repos"
 39query = parse_qs(url.query)
 40assert query["per_page"] == ["100"]
 41page = query["page"][0]
 42with open(os.environ["TEST_API_CALLS"], "a") as calls:
 43    calls.write(page + "\\n")
 44Path(args[args.index("--output") + 1]).write_bytes(
 45    (Path(os.environ["TEST_API_PAGES"]) / (page + ".json")).read_bytes())
 46''')
 47        curl.chmod(0o755)
 48        self.env = os.environ.copy()
 49        # Isolate Git configuration, identities, credentials, and URL rewrites from the host.
 50        self.env.update({
 51            "PATH": str(tools) + os.pathsep + self.env["PATH"],
 52            "GIT_CONFIG_NOSYSTEM": "1",
 53            "GIT_CONFIG_GLOBAL": "/dev/null",
 54            "GIT_CONFIG_COUNT": "1",
 55            "GIT_CONFIG_KEY_0": f"url.{self.sources.as_uri()}/.insteadOf",
 56            "GIT_CONFIG_VALUE_0": "https://github.com/testuser/",
 57            "GIT_AUTHOR_NAME": "Mirror Test",
 58            "GIT_AUTHOR_EMAIL": "mirror@example.com",
 59            "GIT_COMMITTER_NAME": "Mirror Test",
 60            "GIT_COMMITTER_EMAIL": "mirror@example.com",
 61            "TEST_API_PAGES": str(self.fixtures),
 62            "TEST_API_CALLS": str(self.root / "calls"),
 63            "GITHUB_TOKEN": "test-token",
 64        })
 65        self.env.pop("TEST_API_FAILURE", None)
 66
 67    def git(self, directory, *args):
 68        result = subprocess.run(["git", "-C", str(directory), *args], env=self.env,
 69                                text=True, capture_output=True, check=True)
 70        return result.stdout.strip()
 71
 72    def source(self, name, empty=False):
 73        path = self.sources / (name + ".git")
 74        path.mkdir()
 75        self.git(path, "init", "--initial-branch=main")
 76        if not empty:
 77            (path / "README.md").write_text("# Example\n")
 78            self.git(path, "add", "README.md")
 79            self.git(path, "commit", "-m", "Initial source")
 80        return path
 81
 82    def page(self, repos, number=1):
 83        (self.fixtures / f"{number}.json").write_text(json.dumps(repos))
 84
 85    def record(self, name, **overrides):
 86        return {"name": name, "private": False, "default_branch": "main",
 87                "description": "A description\twith tabs\nand newlines.", **overrides}
 88
 89    def run_script(self, success=True):
 90        result = subprocess.run([str(SCRIPT), "testuser", str(self.destination)],
 91                                env=self.env, text=True, capture_output=True, timeout=30)
 92        if success:
 93            self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
 94        else:
 95            self.assertNotEqual(result.returncode, 0)
 96        return result
 97
 98    def test_clone_refresh_and_prune(self):
 99        source = self.source("project")
100        initial_hash = self.git(source, "rev-parse", "HEAD")
101        self.git(source, "branch", "obsolete")
102        self.git(source, "tag", "v1")
103        self.page([self.record("project", fork=True, archived=True)])
104        self.run_script()
105        mirror = self.destination / "project.git"
106        self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD"))
107        self.assertEqual(self.git(mirror, "rev-parse", "--is-bare-repository"), "true")
108        self.assertEqual((mirror / "description").read_text(), self.record("project")["description"] + "\n")
109        self.git(source, "branch", "-m", "trunk")
110        self.git(source, "branch", "-D", "obsolete")
111        self.git(source, "tag", "-d", "v1")
112        (source / "README.md").write_text("# Changed\n")
113        self.git(source, "commit", "-am", "Upstream update")
114        self.page([self.record("project", default_branch="trunk", description=None)])
115        self.run_script()
116        self.assertEqual(self.git(mirror, "symbolic-ref", "HEAD"), "refs/heads/trunk")
117        self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD"))
118        self.assertEqual(self.git(mirror, "for-each-ref", "--format=%(refname)"), "refs/heads/trunk")
119        self.assertEqual((mirror / "description").read_text(), "\n")
120        self.git(source, "update-ref", "refs/heads/trunk", initial_hash)
121        self.run_script()
122        self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), initial_hash)
123
124    def test_pagination_and_public_only_filter(self):
125        self.source("last-page")
126        # A full first page must not stop pagination even if every entry is filtered out.
127        self.page([self.record(f"private-{i}", private=True) for i in range(100)])
128        self.page([self.record("last-page")], number=2)
129        self.run_script()
130        self.assertEqual((self.root / "calls").read_text(), "1\n2\n")
131        self.assertEqual(sorted(p.name for p in self.destination.glob("*.git")), ["last-page.git"])
132
133    def test_preserves_unrelated_destination_and_continues(self):
134        self.source("project")
135        self.source("other")
136        existing = self.destination / "project.git"
137        existing.mkdir(parents=True)
138        (existing / "important.txt").write_text("Keep this file")
139        self.page([self.record("project"), self.record("other")])
140        result = self.run_script(success=False)
141        self.assertIn("not a matching GitHub mirror", result.stderr)
142        self.assertEqual((existing / "important.txt").read_text(), "Keep this file")
143        self.assertTrue((self.destination / "other.git" / "HEAD").is_file())
144
145    def test_refuses_existing_bare_clones_and_foreign_mirrors(self):
146        self.destination.mkdir()
147        for name, mode in (("bare", "--bare"), ("foreign", "--mirror")):
148            source = self.source(name)
149            target = self.destination / (name + ".git")
150            self.git(self.root, "clone", mode, str(source), str(target))
151        self.page([self.record("bare"), self.record("foreign")])
152        self.run_script(success=False)
153        for name in ("bare", "foreign"):
154            target = self.destination / (name + ".git")
155            self.assertEqual(self.git(target, "config", "--get", "remote.origin.url"),
156                             str(self.sources / (name + ".git")))
157            self.assertEqual(self.git(target, "rev-parse", "HEAD"),
158                             self.git(self.sources / (name + ".git"), "rev-parse", "HEAD"))
159
160    def test_refuses_symlink_destination(self):
161        source = self.source("project")
162        self.destination.mkdir()
163        (self.destination / "project.git").symlink_to(source, target_is_directory=True)
164        self.page([self.record("project")])
165        self.run_script(success=False)
166        self.assertFalse((source / "description").exists())
167
168    def test_empty_repository_and_failed_clone(self):
169        self.source("empty", empty=True)
170        self.source("working")
171        self.page([self.record("missing"), self.record("empty"), self.record("working")])
172        result = self.run_script(success=False)
173        self.assertIn("Mirrored 2 repositories", result.stdout)
174        self.assertFalse((self.destination / "missing.git").exists())
175        self.assertTrue((self.destination / "working.git" / "HEAD").is_file())
176        self.assertEqual([p for p in self.destination.glob(".github-mirror.*") if p.is_dir()], [])
177
178    def test_api_failure_does_not_touch_repositories(self):
179        self.env["TEST_API_FAILURE"] = "1"
180        result = self.run_script(success=False)
181        self.assertIn("GitHub API request failed", result.stderr)
182        self.assertEqual(list(self.destination.glob("*.git")), [])
183        self.assertEqual([p for p in self.destination.glob(".github-mirror.*") if p.is_dir()], [])
184
185    def test_destination_lock(self):
186        self.destination.mkdir()
187        self.page([])
188        import fcntl
189        with (self.destination / ".github-mirror.lock").open("w") as lock:
190            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
191            result = self.run_script(success=False)
192            self.assertIn("already running", result.stderr)
193        self.assertFalse((self.root / "calls").exists())
194
195    def test_rejects_invalid_username(self):
196        result = subprocess.run([str(SCRIPT), "../unsafe", str(self.destination)],
197                                env=self.env, text=True, capture_output=True)
198        self.assertNotEqual(result.returncode, 0)
199        self.assertFalse(self.destination.exists())
200
201
202if __name__ == "__main__":
203    unittest.main()