diff --git a/functional_tests/features/gitweb.feature b/functional_tests/features/gitweb.feature
new file mode 100644
index 000000000..7802c91c3
--- /dev/null
+++ b/functional_tests/features/gitweb.feature
@@ -0,0 +1,107 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @gitweb @backups @sso
+Feature: gitweb Simple Git Hosting
+ Git web interface.
+
+Background:
+ Given I'm a logged in user
+ And the gitweb application is installed
+
+Scenario: Enable gitweb application
+ Given the gitweb application is disabled
+ When I enable the gitweb application
+ Then the gitweb site should be available
+
+Scenario: Create public repository
+ Given the gitweb application is enabled
+ And a public repository that doesn't exist
+ When I create the repository
+ Then the repository should be listed as a public
+ And the repository should be listed on gitweb
+
+Scenario: Create private repository
+ Given the gitweb application is enabled
+ And a private repository that doesn't exist
+ When I create the repository
+ Then the repository should be listed as a private
+ And the repository should be listed on gitweb
+
+Scenario: Delete repository
+ Given the gitweb application is enabled
+ And a repository
+ When I delete the repository
+ Then the repository should not be listed
+
+Scenario: Backup and restore gitweb
+ Given the gitweb application is enabled
+ And a repository
+ When I create a backup of the gitweb app data
+ And I delete the repository
+ And I restore the gitweb app data backup
+ Then the repository should be restored
+ And the gitweb site should be available
+
+Scenario: Disable gitweb application
+ Given the gitweb application is enabled
+ When I disable the gitweb application
+ Then the gitweb site should not be available
+
+Scenario: Public gitweb site shows only public repositories
+ Given the gitweb application is enabled
+ And both public and private repositories exist
+ When I log out
+ Then the public repository should be listed on gitweb
+ And the private repository should not be listed on gitweb
+
+Scenario: Gitweb is not public if there are only private repositories
+ Given the gitweb application is enabled
+ And at least one repository exists
+ And all repositories are private
+ When I log out
+ And I access gitweb application
+ Then I should be prompted for login
+ And gitweb app should not be visible on the front page
+
+Scenario: Edit repository metadata
+ Given the gitweb application is enabled
+ And a public repository that doesn't exist
+ And a repository metadata:
+ description: Test Description
+ owner: Test Owner
+ access: private
+ When I create the repository
+ And I set the metadata of the repository
+ Then the metadata of the repository should be as set
+
+Scenario: Access public repository with git client
+ Given the gitweb application is enabled
+ And a public repository
+ When using a git client
+ Then the repository should be publicly readable
+ And the repository should not be publicly writable
+ And the repository should be privately writable
+
+Scenario: Access private repository with git client
+ Given the gitweb application is enabled
+ And a private repository
+ When using a git client
+ Then the repository should not be publicly readable
+ And the repository should not be publicly writable
+ And the repository should be privately readable
+ And the repository should be privately writable
diff --git a/functional_tests/step_definitions/application.py b/functional_tests/step_definitions/application.py
index 41c1a81f9..b5cbc1457 100644
--- a/functional_tests/step_definitions/application.py
+++ b/functional_tests/step_definitions/application.py
@@ -456,3 +456,133 @@ def app_visible_on_front_page(browser, app_name):
def app_not_visible_on_front_page(browser, app_name):
shortcuts = application.find_on_front_page(browser, app_name)
assert len(shortcuts) == 0
+
+
+@given('a public repository')
+@given('a repository')
+@given('at least one repository exists')
+def gitweb_repo(browser):
+ application.gitweb_create_repo(browser, 'Test-repo', 'public', True)
+
+
+@given('a private repository')
+def gitweb_private_repo(browser):
+ application.gitweb_create_repo(browser, 'Test-repo', 'private', True)
+
+
+@given('both public and private repositories exist')
+def gitweb_public_and_private_repo(browser):
+ application.gitweb_create_repo(browser, 'Test-repo', 'public', True)
+ application.gitweb_create_repo(browser, 'Test-repo2', 'private', True)
+
+
+@given(parsers.parse("a {access:w} repository that doesn't exist"))
+def gitweb_nonexistent_repo(browser, access):
+ application.gitweb_delete_repo(browser, 'Test-repo', ignore_missing=True)
+ return dict(access=access)
+
+
+@given('all repositories are private')
+def gitweb_all_repositories_private(browser):
+ application.gitweb_set_all_repos_private(browser)
+
+
+@given(parsers.parse('a repository metadata:\n{metadata}'))
+def gitweb_repo_metadata(browser, metadata):
+ metadata_dict = {}
+ for item in metadata.split('\n'):
+ item = item.split(': ')
+ metadata_dict[item[0]] = item[1]
+ return metadata_dict
+
+
+@when('I create the repository')
+def gitweb_create_repo(browser, access):
+ application.gitweb_create_repo(browser, 'Test-repo', access)
+
+
+@when('I delete the repository')
+def gitweb_delete_repo(browser):
+ application.gitweb_delete_repo(browser, 'Test-repo')
+
+
+@when('I set the metadata of the repository')
+def gitweb_edit_repo_metadata(browser, gitweb_repo_metadata):
+ application.gitweb_edit_repo_metadata(browser, 'Test-repo',
+ gitweb_repo_metadata)
+
+
+@when('using a git client')
+def gitweb_using_git_client():
+ pass
+
+
+@then('the repository should be restored')
+@then('the repository should be listed as a public')
+def gitweb_repo_should_exists(browser):
+ assert application.gitweb_repo_exists(browser, 'Test-repo',
+ access='public')
+
+
+@then('the repository should be listed as a private')
+def gitweb_private_repo_should_exists(browser):
+ assert application.gitweb_repo_exists(browser, 'Test-repo', 'private')
+
+
+@then('the repository should not be listed')
+def gitweb_repo_should_not_exist(browser, gitweb_repo):
+ assert not application.gitweb_repo_exists(browser, gitweb_repo)
+
+
+@then('the public repository should be listed on gitweb')
+@then('the repository should be listed on gitweb')
+def gitweb_repo_should_exist_on_gitweb(browser):
+ assert application.gitweb_site_repo_exists(browser, 'Test-repo')
+
+
+@then('the private repository should not be listed on gitweb')
+def gitweb_private_repo_should_exists_on_gitweb(browser):
+ assert not application.gitweb_site_repo_exists(browser, 'Test-repo2')
+
+
+@then('the metadata of the repository should be as set')
+def gitweb_repo_metadata_should_match(browser, gitweb_repo_metadata):
+ actual_metadata = application.gitweb_get_repo_metadata(
+ browser, 'Test-repo')
+ assert all(item in actual_metadata.items()
+ for item in gitweb_repo_metadata.items())
+
+
+@then('the repository should be publicly readable')
+def gitweb_repo_publicly_readable():
+ assert application.gitweb_repo_is_readable('Test-repo')
+ assert application.gitweb_repo_is_readable('Test-repo',
+ url_git_extension=True)
+
+
+@then('the repository should not be publicly readable')
+def gitweb_repo_not_publicly_readable():
+ assert not application.gitweb_repo_is_readable('Test-repo')
+ assert not application.gitweb_repo_is_readable('Test-repo',
+ url_git_extension=True)
+
+
+@then('the repository should not be publicly writable')
+def gitweb_repo_not_publicly_writable():
+ assert not application.gitweb_repo_is_writable('Test-repo')
+ assert not application.gitweb_repo_is_writable('Test-repo',
+ url_git_extension=True)
+
+
+@then('the repository should be privately readable')
+def gitweb_repo_privately_readable():
+ assert application.gitweb_repo_is_readable('Test-repo', with_auth=True)
+ assert application.gitweb_repo_is_readable('Test-repo', with_auth=True,
+ url_git_extension=True)
+
+
+@then('the repository should be privately writable')
+def gitweb_repo_privately_writable():
+ assert application.gitweb_repo_is_writable('Test-repo', with_auth=True)
+ assert application.gitweb_repo_is_writable('Test-repo', with_auth=True,
+ url_git_extension=True)
diff --git a/functional_tests/support/application.py b/functional_tests/support/application.py
index 76d9714bf..fca0f0ef0 100644
--- a/functional_tests/support/application.py
+++ b/functional_tests/support/application.py
@@ -15,6 +15,11 @@
# along with this program. If not, see .
#
+import contextlib
+import os
+import shutil
+import subprocess
+import tempfile
from time import sleep
import requests
@@ -319,6 +324,175 @@ def ejabberd_has_contact(browser):
return eventually(site.jsxc_has_contact, [browser])
+def gitweb_create_repo(browser, repo, access=None, ok_if_exists=False):
+ """Create repository."""
+ if not gitweb_repo_exists(browser, repo, access):
+ gitweb_delete_repo(browser, repo, ignore_missing=True)
+ browser.find_link_by_href('/plinth/apps/gitweb/create/').first.click()
+ browser.find_by_id('id_gitweb-name').fill(repo)
+ if access == 'private':
+ browser.find_by_id('id_gitweb-is_private').check()
+ elif access == 'public':
+ browser.find_by_id('id_gitweb-is_private').uncheck()
+ submit(browser)
+ elif not ok_if_exists:
+ assert False, 'Repo already exists.'
+
+
+def gitweb_delete_repo(browser, repo, ignore_missing=False):
+ """Delete repository."""
+ interface.nav_to_module(browser, 'gitweb')
+ delete_link = browser.find_link_by_href(
+ '/plinth/apps/gitweb/{}/delete/'.format(repo))
+ if delete_link or not ignore_missing:
+ delete_link.first.click()
+ submit(browser)
+
+
+def gitweb_edit_repo_metadata(browser, repo, metadata):
+ """Set repository metadata."""
+ interface.nav_to_module(browser, 'gitweb')
+ browser.find_link_by_href(
+ '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click()
+ if 'name' in metadata:
+ browser.find_by_id('id_gitweb-name').fill(metadata['name'])
+ if 'description' in metadata:
+ browser.find_by_id('id_gitweb-description').fill(
+ metadata['description'])
+ if 'owner' in metadata:
+ browser.find_by_id('id_gitweb-owner').fill(metadata['owner'])
+ if 'access' in metadata:
+ if metadata['access'] == 'private':
+ browser.find_by_id('id_gitweb-is_private').check()
+ else:
+ browser.find_by_id('id_gitweb-is_private').uncheck()
+ submit(browser)
+
+
+def gitweb_get_repo_metadata(browser, repo):
+ """Get repository metadata."""
+ interface.nav_to_module(browser, 'gitweb')
+ browser.find_link_by_href(
+ '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click()
+ metadata = {}
+ for item in ['name', 'description', 'owner']:
+ metadata[item] = browser.find_by_id('id_gitweb-' + item).value
+ if browser.find_by_id('id_gitweb-is_private').value:
+ metadata['access'] = 'private'
+ else:
+ metadata['access'] = 'public'
+ return metadata
+
+
+def _gitweb_get_repo_url(repo, with_auth):
+ """"Get repository URL"""
+ scheme = 'http'
+ if default_url.startswith('https://'):
+ scheme = 'https'
+ url = default_url.split('://')[1] if '://' in default_url else default_url
+ password = 'gitweb_wrong_password'
+ if with_auth:
+ password = config['DEFAULT']['password']
+
+ return '{0}://{1}:{2}@{3}/gitweb/{4}'.format(
+ scheme, config['DEFAULT']['username'], password, url, repo)
+
+
+@contextlib.contextmanager
+def _gitweb_temp_directory():
+ """Create temporary directory"""
+ name = tempfile.mkdtemp(prefix='plinth_test_gitweb_')
+ yield name
+ shutil.rmtree(name)
+
+
+def _gitweb_git_command_is_successful(command, cwd):
+ """Check if a command runs successfully or gives authentication error"""
+ process = subprocess.run(command, capture_output=True, cwd=cwd)
+ if process.returncode != 0:
+ if 'Authentication failed' in process.stderr.decode():
+ return False
+ print(process.stdout.decode())
+ # raise exception
+ process.check_returncode()
+ return True
+
+
+def gitweb_repo_exists(browser, repo, access=None):
+ """Check whether the repository exists."""
+ interface.nav_to_module(browser, 'gitweb')
+ links_found = browser.find_link_by_href('/gitweb/{}.git'.format(repo))
+ access_matches = True
+ if links_found and access:
+ parent = links_found.first.find_by_xpath('..').first
+ private_icon = parent.find_by_css('.repo-private-icon')
+ if access == 'private':
+ access_matches = True if private_icon else False
+ if access == 'public':
+ access_matches = True if not private_icon else False
+ return bool(links_found) and access_matches
+
+
+def gitweb_repo_is_readable(repo, with_auth=False, url_git_extension=False):
+ """Check if a git repo is readable with git client."""
+ url = _gitweb_get_repo_url(repo, with_auth)
+ if url_git_extension:
+ url = url + '.git'
+ git_command = ['git', 'clone', '-c', 'http.sslverify=false', url]
+ with _gitweb_temp_directory() as cwd:
+ return _gitweb_git_command_is_successful(git_command, cwd)
+
+
+def gitweb_repo_is_writable(repo, with_auth=False, url_git_extension=False):
+ """Check if a git repo is writable with git client."""
+ url = _gitweb_get_repo_url(repo, with_auth)
+ if url_git_extension:
+ url = url + '.git'
+
+ with _gitweb_temp_directory() as cwd:
+ subprocess.run(['mkdir', 'test-project'], check=True, cwd=cwd)
+ cwd = os.path.join(cwd, 'test-project')
+ prepare_git_repo_commands = [
+ 'git init -q', 'git config http.sslVerify false',
+ 'git commit -q --allow-empty --author "Tester <>" -m "test"'
+ ]
+ for command in prepare_git_repo_commands:
+ subprocess.run(command, shell=True, check=True, cwd=cwd)
+ git_push_command = ['git', 'push', '-qf', url, 'master']
+
+ return _gitweb_git_command_is_successful(git_push_command, cwd)
+
+
+def gitweb_set_repo_access(browser, repo, access):
+ """Set repository as public or private."""
+ interface.nav_to_module(browser, 'gitweb')
+ browser.find_link_by_href(
+ '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click()
+ if access == 'private':
+ browser.find_by_id('id_gitweb-is_private').check()
+ else:
+ browser.find_by_id('id_gitweb-is_private').uncheck()
+ submit(browser)
+
+
+def gitweb_set_all_repos_private(browser):
+ """Set all repositories private"""
+ interface.nav_to_module(browser, 'gitweb')
+ public_repos = []
+ for element in browser.find_by_css('#gitweb-repo-list .list-group-item'):
+ if not element.find_by_css('.repo-private-icon'):
+ repo = element.find_by_css('.repo-label').first.text
+ public_repos.append(repo)
+ for repo in public_repos:
+ gitweb_set_repo_access(browser, repo, 'private')
+
+
+def gitweb_site_repo_exists(browser, repo):
+ """Check whether the repository exists on Gitweb site."""
+ browser.visit('{}/gitweb'.format(default_url))
+ return browser.find_by_css('a[href="/gitweb/{0}.git"]'.format(repo))
+
+
def ikiwiki_create_wiki_if_needed(browser):
"""Create wiki if it does not exist."""
interface.nav_to_module(browser, 'ikiwiki')
diff --git a/plinth/modules/gitweb/templates/gitweb_configure.html b/plinth/modules/gitweb/templates/gitweb_configure.html
index 84b615f33..72e0d041a 100644
--- a/plinth/modules/gitweb/templates/gitweb_configure.html
+++ b/plinth/modules/gitweb/templates/gitweb_configure.html
@@ -54,7 +54,7 @@
{% if not repos %}
{% trans 'No repositories available.' %}
{% else %}
-