wrolpi/modules/repos/test/test_import.py
lrnselfreliance 93d878952c
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.

- A Repo is a Collection (kind='repo') plus a `repository` row.  The new `git` downloader clones it, then updates it
  (weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
  An existing clone can be imported where it is.  repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download.  READMEs are searchable
  under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
  repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
  updated.
- Tagging a Repo moves its clone as one directory.  Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
  destination.  A failed tag move restores the Collection's tag.  A Collection's directory cannot leave the media
  directory.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:58 -06:00

287 lines
14 KiB
Python

import json
import subprocess
from http import HTTPStatus
import pytest
from modules.repos import lib
from modules.repos.errors import InvalidRepo, RepoConflict
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadStatus
from wrolpi.errors import UnknownDirectory, ValidationError
from wrolpi.files.models import FileGroup
def git(directory, *args) -> str:
env = dict(GIT_AUTHOR_NAME='t', GIT_AUTHOR_EMAIL='t@t', GIT_COMMITTER_NAME='t', GIT_COMMITTER_EMAIL='t@t',
PATH='/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin')
return subprocess.run(['git', '-c', 'safe.directory=*', *args], cwd=directory, check=True, env=env,
capture_output=True).stdout.decode().strip()
@pytest.fixture
def user_clone(test_directory, git_remote_factory):
"""A clone the user made themselves, in their media directory, with an `origin`."""
def factory(relative='code/kiwix-tools', remote=None):
remote = remote or git_remote_factory('kiwix-tools', owner='kiwix', files={'README.md': '# Kiwix tools'})
directory = test_directory / relative
directory.parent.mkdir(parents=True, exist_ok=True)
git(directory.parent, 'clone', '--quiet', str(remote.path), directory.name)
git(directory, 'remote', 'set-url', 'origin', remote.url)
return directory, remote
return factory
@pytest.mark.parametrize('origin,expected', [
('https://github.com/kiwix/kiwix-tools', 'https://github.com/kiwix/kiwix-tools'),
('https://github.com/kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools.git'),
('git@github.com:kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools'),
('ssh://git@github.com:22/kiwix/kiwix-tools.git', 'https://github.com/kiwix/kiwix-tools'),
('git://github.com/kiwix/kiwix-tools', 'https://github.com/kiwix/kiwix-tools'),
('/home/user/kiwix-tools', None),
('../kiwix-tools', None),
])
def test_remote_https_url(origin, expected):
assert lib.remote_https_url(origin) == expected
def test_remote_url_key():
assert lib.remote_url_key('git@GitHub.com:kiwix/kiwix-tools.git') == \
lib.remote_url_key('https://github.com/kiwix/kiwix-tools/') == 'github.com/kiwix/kiwix-tools'
assert lib.remote_url_key('https://github.com/kiwix/other') != lib.remote_url_key('https://github.com/kiwix/x')
assert lib.remote_url_key('/local/path') is None
@pytest.mark.asyncio
async def test_inspect_import(test_session, async_client, test_wrolpi_config, user_clone):
"""Inspecting a clone reports what an import would do, and changes nothing."""
directory, remote = user_clone()
git(directory, 'commit', '--quiet', '--allow-empty', '-m', 'Local work')
config_before = (directory / '.git/config').read_text()
request, response = await async_client.post('/api/repos/import/inspect',
content=json.dumps(dict(directory='code/kiwix-tools')))
assert response.status_code == HTTPStatus.OK, response.json
info = response.json['inspection']
assert info['directory'] == 'code/kiwix-tools'
assert info['origin'] == remote.url
assert info['url'] == remote.url
assert info['name'] == 'kiwix-tools'
assert info['branch'] == 'main'
assert info['head_message'] == 'Local work'
assert info['head_sha'] == git(directory, 'rev-parse', 'HEAD')
assert info['head_date']
assert info['local_commits'] == 1
assert info['shallow'] is False
# The clone stays where it is, where its files are indexed.
assert info['ignored'] is False
assert (directory / '.git/config').read_text() == config_before
assert not (directory / '.git' / lib.REPO_MARKER).exists()
# A clone in the Repos directory is ignored.
user_clone('repos/other', remote=remote)
assert lib.inspect_import(test_session, 'repos/other')['ignored'] is True
@pytest.mark.asyncio
async def test_inspect_import_refuses(test_session, test_directory, async_client, test_wrolpi_config, user_clone):
(test_directory / 'not-git').mkdir()
with pytest.raises(InvalidRepo):
lib.inspect_import(test_session, 'not-git')
with pytest.raises(UnknownDirectory):
lib.inspect_import(test_session, 'missing')
for directory in ('', '/etc', '../outside', 'config', 'tags', 'videos'):
(test_directory / 'videos').mkdir(exist_ok=True)
with pytest.raises((InvalidRepo, UnknownDirectory)):
lib.inspect_import(test_session, directory)
# `.git` must be the clone's own directory.
(test_directory / 'gitfile').mkdir()
(test_directory / 'gitfile/.git').write_text('gitdir: /etc\n')
with pytest.raises(InvalidRepo):
lib.inspect_import(test_session, 'gitfile')
# A partial clone's missing objects would come from the network.
directory, _ = user_clone('code/partial')
git(directory, 'config', 'remote.origin.promisor', 'true')
with pytest.raises(InvalidRepo, match='partial clone'):
lib.inspect_import(test_session, 'code/partial')
# Already a Repo, or inside one.
repo = lib.create_repository(test_session, 'https://github.com/a/b')
repo.directory.mkdir(parents=True)
git(repo.directory, 'init', '--quiet')
with pytest.raises(RepoConflict):
lib.inspect_import(test_session, 'repos/b')
@pytest.mark.asyncio
async def test_import(test_session, test_directory, async_client, test_wrolpi_config, user_clone,
repos_download_manager, refresh_files):
"""An imported clone stays where it is, is owned by WROLPi, and is updated like any Repo."""
directory, remote = user_clone()
(directory / 'local-note.txt').write_text('untracked')
with (directory / '.git/config').open('a') as fh:
fh.write('[http]\n\tproxy = http://127.0.0.1:9\n[user]\n\tname = Someone\n')
await refresh_files()
indexed = test_session.query(FileGroup).filter(FileGroup.primary_path.contains('code/kiwix-tools')).count()
assert indexed
body = dict(directory='code/kiwix-tools', tag_name='Software', confirm=True, frequency=86400)
request, response = await async_client.post('/api/repos/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
imported = response.json['repo']
assert imported['name'] == 'kiwix-tools'
assert imported['url'] == remote.url
assert imported['directory'] == 'code/kiwix-tools'
assert imported['tag_name'] == 'Software'
assert imported['frequency'] == 86400
assert imported['branch'] is None # Follows the default branch, like a new Repo.
assert imported['head_sha'] == remote.head
assert imported['readme_path'] == 'README.md'
assert (directory / 'README.md').is_file()
assert (directory / '.git' / lib.REPO_MARKER).is_file()
config = (directory / '.git/config').read_text()
assert 'proxy' not in config and 'Someone' not in config
# It was not moved (or ignored); its files are still indexed.
assert not (test_directory / 'repos').exists()
await refresh_files()
assert test_session.query(FileGroup).filter(FileGroup.primary_path.contains('code/kiwix-tools')).count()
# From now on it is a mirror: an update fetches the origin, and discards local files.
await repos_download_manager.wait_for_all_downloads()
head = remote.commit({'new.txt': 'new'}, 'New upstream')
download = test_session.query(Download).one()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
download = test_session.query(Download).one()
assert download.status == DownloadStatus.complete, download.error
assert test_session.query(Repository).one().head_sha == head
assert (directory / 'new.txt').is_file()
assert not (directory / 'local-note.txt').exists()
@pytest.mark.asyncio
async def test_import_refuses(test_session, test_directory, async_client, test_wrolpi_config, user_clone,
git_remote_factory):
directory, remote = user_clone()
# Without confirmation.
with pytest.raises(ValidationError):
await lib.import_repository(test_session, 'code/kiwix-tools')
# A URL which is not the clone's origin.
with pytest.raises(InvalidRepo, match="not this clone's origin"):
await lib.import_repository(test_session, 'code/kiwix-tools', url='https://github.com/evil/other',
confirm=True)
test_session.rollback()
assert test_session.query(Repository).count() == 0
assert not (directory / '.git' / lib.REPO_MARKER).exists()
# Without an https origin, the URL must be given (and must still be the origin).
other, _ = user_clone('code/ssh-clone', remote=git_remote_factory('library'))
git(other, 'remote', 'set-url', 'origin', 'git@git.example.com:owner/library.git')
assert lib.inspect_import(test_session, 'code/ssh-clone')['url'] == 'https://git.example.com/owner/library'
git(other, 'remote', 'remove', 'origin')
assert lib.inspect_import(test_session, 'code/ssh-clone')['url'] is None
with pytest.raises(InvalidRepo, match='https:// URL'):
await lib.import_repository(test_session, 'code/ssh-clone', confirm=True)
repo = await lib.import_repository(test_session, 'code/ssh-clone', url='https://git.example.com/owner/library',
confirm=True)
assert repo.url == 'https://git.example.com/owner/library'
assert repo.directory == other
@pytest.mark.asyncio
async def test_refused_import_changes_nothing(test_session, test_directory, async_client, test_wrolpi_config,
user_clone):
"""Everything which can refuse an import is checked before the clone is changed."""
directory, _ = user_clone()
(directory / 'other').mkdir()
(directory / 'other/.git').write_text(f'gitdir: {test_directory}\n')
config_before = (directory / '.git/config').read_text()
with pytest.raises(InvalidRepo, match='outside'):
await lib.import_repository(test_session, 'code/kiwix-tools', confirm=True)
test_session.rollback()
assert (directory / '.git/config').read_text() == config_before
assert not (directory / '.git' / lib.REPO_MARKER).exists()
assert test_session.query(Repository).count() == 0
@pytest.mark.asyncio
async def test_inspect_never_reads_the_clones_config(test_session, test_directory, async_client, test_wrolpi_config,
user_clone):
"""Inspecting runs no git command with the clone's (unsanitized) config: one git refuses to read still inspects."""
directory, remote = user_clone()
with (directory / '.git/config').open('a') as fh:
fh.write('[core]\n\trepositoryformatversion = 1\n[extensions]\n\tnotAnExtension = true\n'
f'[include]\n\tpath = {test_directory}/included.gitconfig\n')
# git itself refuses this repository.
assert subprocess.run(['git', '-c', 'safe.directory=*', 'rev-parse', 'HEAD'], cwd=directory,
capture_output=True).returncode != 0
info = lib.inspect_import(test_session, 'code/kiwix-tools')
assert info['head_sha'] == remote.head
assert info['head_message'] == 'Initial commit'
assert info['local_commits'] == 0
def test_working_tree_git_file_is_read_like_git(tmp_path):
"""git trims only the line ending from a `.git` file, not spaces; so does WROLPi."""
repo = tmp_path / 'repo'
(repo / '.git/modules/library/objects').mkdir(parents=True)
(repo / '.git/modules/library/HEAD').write_text('ref: refs/heads/main\n')
(repo / 'vendor/library').mkdir(parents=True)
# git reads this as "<...>/library " (with the space): not the checked git directory.
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/library/../../../.. \n')
assert lib.working_tree_git_error(repo)
(repo / 'vendor/library/.git').write_text('gitdir: ../../.git/modules/library\r\n')
assert lib.working_tree_git_error(repo) is None
@pytest.mark.asyncio
async def test_create_repository_in_a_directory(test_session, test_directory, async_client, test_wrolpi_config,
git_remote_factory, repos_download_manager, refresh_files):
"""A Repo can be cloned into a directory the user chooses. It is not ignored: its files are indexed."""
remote = git_remote_factory(files={'README.md': '# Example', 'guide.md': 'A guide'})
body = dict(url=remote.url, directory='projects/example', tag_name='Software')
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
assert response.json['repo']['directory'] == 'projects/example'
await repos_download_manager.wait_for_all_downloads()
directory = test_directory / 'projects/example'
assert (directory / 'guide.md').is_file()
await refresh_files()
assert test_session.query(FileGroup).filter(FileGroup.primary_path.contains('projects/example')).count()
@pytest.mark.parametrize('directory,error', [
('/etc/example', 'relative'),
('../example', 'relative'),
('.', 'media directory'),
('videos', 'cannot be used'),
('config/example', 'cannot be used'),
('tags', 'cannot be used'),
('not-empty', 'not empty'),
('existing-repo/inside', 'Collection'),
])
@pytest.mark.asyncio
async def test_create_repository_directory_refused(test_session, test_directory, async_client, test_wrolpi_config,
directory, error):
(test_directory / 'videos').mkdir()
(test_directory / 'not-empty').mkdir()
(test_directory / 'not-empty/file.txt').write_text('mine')
lib.create_repository(test_session, 'https://github.com/a/existing', directory='existing-repo')
body = dict(url='https://github.com/a/b', directory=directory)
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.CONFLICT), response.json
assert error in response.json['error'], response.json
assert (test_directory / 'not-empty/file.txt').read_text() == 'mine'