Repository: carpentries/amy

Issue:
GitHub username containing spaces results in Internal Server Error on person detailed view
Check it out https://amy.software-carpentry.org/workshops/person/10483/

We get an exception when we ask GitHub API for user ID and we pass invalid GitHub username. This doesn't result in the exception for other invalid usernames. We ask GitHub API to check if SocialAuthTable is in sync and if the person can login using their GitHub account.
GitHub username containing spaces results in Internal Server Error on person detailed view
Check it out https://amy.software-carpentry.org/workshops/person/10483/

We get an exception when we ask GitHub API for user ID and we pass invalid GitHub username. This doesn't result in the exception for other invalid usernames. We ask GitHub API to check if SocialAuthTable is in sync and if the person can login using their GitHub account.


Discussion:
This error happens if the invalid username contains a space.
This error happens if the invalid username contains a space.

Resolving patch:
diff --git a/workshops/github_auth.py b/workshops/github_auth.py
--- a/workshops/github_auth.py
+++ b/workshops/github_auth.py
@@ -32,6 +32,15 @@ def github_username_to_uid(username):
     Raises GithubException in the case of IO issues."""
 
     g = Github(settings.GITHUB_API_TOKEN)
+
+    # Github.get_user(username) is buggy and raises ConnectionResetError when
+    # username contains spaces (see #1141). Spaces in GitHub usernames are
+    # forbidden, so we safely assume that there is no user with a space in their
+    # username.
+    if ' ' in username:
+        msg = 'There is no github user with login "{}"'.format(username)
+        raise ValueError(msg)
+
     try:
         user = g.get_user(username)
     except UnknownObjectException as e:
diff --git a/workshops/migrations/0126_auto_20170325_0406.py b/workshops/migrations/0126_auto_20170325_0406.py
new file mode 100644
--- /dev/null
+++ b/workshops/migrations/0126_auto_20170325_0406.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.12 on 2017-03-25 09:06
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('workshops', '0125_trainingrequest_notes'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='person',
+            name='github',
+            field=models.CharField(blank=True, help_text='Please put only a single username here.', max_length=40, null=True, unique=True, validators=[django.core.validators.RegexValidator('[, ]+', 'Spaces or commas are disallowed.', inverse_match=True)], verbose_name='GitHub username'),
+        ),
+        migrations.AlterField(
+            model_name='profileupdaterequest',
+            name='github',
+            field=models.CharField(blank=True, default='', help_text='Please put only a single username here.', max_length=100, validators=[django.core.validators.RegexValidator('[, ]+', 'Spaces or commas are disallowed.', inverse_match=True)], verbose_name='GitHub username'),
+        ),
+        migrations.AlterField(
+            model_name='trainingrequest',
+            name='github',
+            field=models.CharField(blank=True, help_text='Please put only a single username here.', max_length=100, null=True, validators=[django.core.validators.RegexValidator('[, ]+', 'Spaces or commas are disallowed.', inverse_match=True)], verbose_name='GitHub username'),
+        ),
+    ]
diff --git a/workshops/models.py b/workshops/models.py
--- a/workshops/models.py
+++ b/workshops/models.py
@@ -300,6 +300,10 @@ def passed(requirement):
                          F('passed_dc_demo')),
         )
 
+github_username_validator = RegexValidator(r'[, ]+',
+                                           'Spaces or commas are disallowed.',
+                                           inverse_match=True)
+
 
 @reversion.register
 class Person(AbstractBaseUser, PermissionsMixin):
@@ -333,7 +337,9 @@ class Person(AbstractBaseUser, PermissionsMixin):
     airport     = models.ForeignKey(Airport, null=True, blank=True, on_delete=models.PROTECT,
                                     verbose_name='Nearest major airport')
     github      = models.CharField(max_length=STR_MED, unique=True, null=True, blank=True,
-                                   verbose_name='GitHub username')
+                                   verbose_name='GitHub username',
+                                   validators=[github_username_validator],
+                                   help_text='Please put only a single username here.',)
     twitter     = models.CharField(max_length=STR_MED, unique=True, null=True, blank=True,
                                    verbose_name='Twitter username')
     url         = models.CharField(max_length=STR_LONG, blank=True,
@@ -615,7 +621,8 @@ class ProfileUpdateRequest(ActiveMixin, CreatedUpdatedMixin, models.Model):
     github = models.CharField(
         max_length=STR_LONG,
         verbose_name='GitHub username',
-        help_text='Please provide your username, not a numeric user ID.',
+        help_text='Please put only a single username here.',
+        validators=[github_username_validator],
         blank=True, default='',
     )
     twitter = models.CharField(
@@ -1969,6 +1976,8 @@ class TrainingRequest(ActiveMixin, CreatedUpdatedMixin, models.Model):
     github = models.CharField(
         max_length=STR_LONG,
         verbose_name='GitHub username',
+        validators=[github_username_validator],
+        help_text='Please put only a single username here.',
         null=True, blank=True,
     )
 


Test patch:
diff --git a/workshops/test/test_github_auth.py b/workshops/test/test_github_auth.py
new file mode 100644
--- /dev/null
+++ b/workshops/test/test_github_auth.py
@@ -0,0 +1,18 @@
+from unittest.mock import patch
+
+from .base import TestBase
+from workshops.github_auth import github_username_to_uid
+
+
+class TestGithubUsernameToUid(TestBase):
+    @patch('workshops.github_auth.Github.get_user')
+    def test_regression_1141(self, get_user_mock):
+        """
+        Github.get_user(username) is buggy and raises ConnectionResetError when
+        username contains spaces. Test that this case is handled correctly.
+        """
+
+        get_user_mock.side_effect = ConnectionResetError
+        
+        with self.assertRaises(ValueError):
+            got = github_username_to_uid('asdf qwer')  
diff --git a/workshops/test/test_person.py b/workshops/test/test_person.py
--- a/workshops/test/test_person.py
+++ b/workshops/test/test_person.py
@@ -467,6 +467,28 @@ def test_awarding_instructor_badge_workflow(self):
         self.assertRedirects(dc_res.forms[2].submit(), trainees.request.url)
         self.assertEqual(trainee.award_set.last().badge, self.dc_instructor)
 
+    def test_person_github_username_validation(self):
+        """Ensure GitHub username doesn't allow for spaces or commas."""
+        invalid_usernames = ['Harry James Potter', 'Harry, Hermione, Ron']
+        for key, username in enumerate(invalid_usernames):
+            with self.subTest(username=username):
+                person = Person.objects.create(
+                    personal='Testing', family='Testing',
+                    username='testing{}'.format(key),
+                    github=username,
+                )
+                with self.assertRaises(ValidationError) as cm:
+                    person.clean_fields(exclude=['password'])
+                self.assertIn('github', cm.exception.message_dict)
+
+        valid_username = 'blanking-crush_andy'
+        person = Person.objects.create(
+            personal='Andy', family='Blanking-Crush',
+            username='blanking-crush_andy',
+            github=valid_username,
+        )
+        person.clean_fields(exclude=['password'])
+
 
 class TestPersonPassword(TestBase):
     """Separate tests for testing password setting.