Repository: nautobot/nautobot

Issue:
Unable to sync a git repository if the token contains some special characters

### Environment
* Python version: 3.7
* Nautobot version: 1.0.0

### Steps to Reproduce
1. Create a new Git repository
2. Add a token containing a special character like `/`  
3. Try to sync the repository

### Expected Behavior
Either it shouldn't be possible to create a repository with an invalid token or the sync should work as expected

### Observed Behavior
The synchronization fail with an exit code of 128 and the following messages
- name : `Test Wrong token`
- username: `test`
- token `qwertytrew/qewrtsdfgtrew`
- repo : `https://github.com/networktocode-llc/nautobot-datasource`

```
2021-05-01T20:25:14.013404 | Failure |   | Cmd('git') failed due to: exit code(128) cmdline: git clone -v --progress https://test:qwertytrew/qewrtsdfgtrew@github.com/networktocode-llc/nautobot-datasource /opt/nautobot/git/test-wrong-token
2021-05-01T20:25:14.045399 | Failure |   | Error while refreshing Test Wrong token: Cmd('git') failed due to: exit code(128) cmdline: git clone -v --progress https://test:qwertytrew/qewrtsdfgtrew@github.com/networktocode-llc/nautobot-datasource /opt/nautobot/git/test-wrong-token
```



Discussion:
Thanks for the report! Looks like we probably need to url-encode the token when we add it to the URL in `ensure_git_repository()`.

Resolving patch:
diff --git a/nautobot/extras/datasources/git.py b/nautobot/extras/datasources/git.py
--- a/nautobot/extras/datasources/git.py
+++ b/nautobot/extras/datasources/git.py
@@ -4,6 +4,7 @@
 import logging
 import os
 import re
+from urllib.parse import quote
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
@@ -124,9 +125,9 @@ def ensure_git_repository(repository_record, job_result=None, logger=None, head=
     if token and token not in from_url:
         # Some git repositories require a user as well as a token.
         if user:
-            from_url = re.sub("//", f"//{user}:{token}@", from_url)
+            from_url = re.sub("//", f"//{quote(user, safe='')}:{quote(token, safe='')}@", from_url)
         else:
-            from_url = re.sub("//", f"//{token}@", from_url)
+            from_url = re.sub("//", f"//{quote(token, safe='')}@", from_url)
 
     to_path = repository_record.filesystem_path
     from_branch = repository_record.branch


Test patch:
diff --git a/nautobot/extras/tests/test_datasources.py b/nautobot/extras/tests/test_datasources.py
--- a/nautobot/extras/tests/test_datasources.py
+++ b/nautobot/extras/tests/test_datasources.py
@@ -73,13 +73,13 @@ def setUp(self):
 
     def test_pull_git_repository_and_refresh_data_with_no_data(self, MockGitRepo):
         """
-        The test_pull_git_repository_and_refresh_data job should succeeed if the given repo is empty.
+        The pull_git_repository_and_refresh_data job should succeed if the given repo is empty.
         """
         with tempfile.TemporaryDirectory() as tempdir:
             with self.settings(GIT_ROOT=tempdir):
 
                 def create_empty_repo(path, url):
-                    os.makedirs(path)
+                    os.makedirs(path, exist_ok=True)
                     return mock.DEFAULT
 
                 MockGitRepo.side_effect = create_empty_repo
@@ -94,8 +94,52 @@ def create_empty_repo(path, url):
                 )
                 self.repo.refresh_from_db()
                 self.assertEqual(self.repo.current_head, self.COMMIT_HEXSHA, self.job_result.data)
+                MockGitRepo.assert_called_with(os.path.join(tempdir, self.repo.slug), "http://localhost/git.git")
                 # TODO: inspect the logs in job_result.data?
 
+                # Check that token-based authentication is handled as expected
+                self.repo._token = "1:3@/?=ab@"
+                self.repo.save()
+                # For verisimilitude, don't re-use the old request and job_result
+                self.dummy_request.id = uuid.uuid4()
+                self.job_result = JobResult(
+                    name=self.repo.name,
+                    obj_type=ContentType.objects.get_for_model(GitRepository),
+                    job_id=uuid.uuid4(),
+                )
+                pull_git_repository_and_refresh_data(self.repo.pk, self.dummy_request, self.job_result)
+
+                self.assertEqual(
+                    self.job_result.status,
+                    JobResultStatusChoices.STATUS_COMPLETED,
+                    self.job_result.data,
+                )
+                MockGitRepo.assert_called_with(
+                    os.path.join(tempdir, self.repo.slug), "http://1%3A3%40%2F%3F%3Dab%40@localhost/git.git"
+                )
+
+                # Check that username/password authentication is handled as expected
+                self.repo.username = "núñez"
+                self.repo.save()
+                # For verisimilitude, don't re-use the old request and job_result
+                self.dummy_request.id = uuid.uuid4()
+                self.job_result = JobResult(
+                    name=self.repo.name,
+                    obj_type=ContentType.objects.get_for_model(GitRepository),
+                    job_id=uuid.uuid4(),
+                )
+                pull_git_repository_and_refresh_data(self.repo.pk, self.dummy_request, self.job_result)
+
+                self.assertEqual(
+                    self.job_result.status,
+                    JobResultStatusChoices.STATUS_COMPLETED,
+                    self.job_result.data,
+                )
+                MockGitRepo.assert_called_with(
+                    os.path.join(tempdir, self.repo.slug),
+                    "http://n%C3%BA%C3%B1ez:1%3A3%40%2F%3F%3Dab%40@localhost/git.git",
+                )
+
     def test_pull_git_repository_and_refresh_data_with_valid_data(self, MockGitRepo):
         """
         The test_pull_git_repository_and_refresh_data job should succeed if valid data is present in the repo.