Repository: hedyorg/hedy

Issue:
[BUG] Users are unable to empty optional profile fields after setting a value
**Describe the bug**
Currently when a users updates their profile with an optional field everything works fine. However, when trying to remove this set field (by leaving it empty in the update form) this change won't be updated in the database as well. This is due to the structure of our back-end and we should write an additional function to verify if currently found fields in the database are still in the updated inputs, if not: delete them from the database.

**Expected behavior**
When users leave an earlier saved optional field empty the old value should be correctly deleted from the database.

[BUG] Users are unable to empty optional profile fields after setting a value
**Describe the bug**
Currently when a users updates their profile with an optional field everything works fine. However, when trying to remove this set field (by leaving it empty in the update form) this change won't be updated in the database as well. This is due to the structure of our back-end and we should write an additional function to verify if currently found fields in the database are still in the updated inputs, if not: delete them from the database.

**Expected behavior**
When users leave an earlier saved optional field empty the old value should be correctly deleted from the database.



Discussion:

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -278,6 +278,7 @@ def setup_language():
     # Also get the 'ui' translations into a global object for this language, these
     # are used a lot so we can clean up a fair bit by initializing here.
     g.ui_texts = TRANSLATIONS.get_translations(g.lang, 'ui')
+    g.auth_texts = TRANSLATIONS.get_translations(g.lang, 'Auth')
 
 
 if utils.is_heroku() and not os.getenv('HEROKU_RELEASE_CREATED_AT'):
diff --git a/website/auth.py b/website/auth.py
--- a/website/auth.py
+++ b/website/auth.py
@@ -5,7 +5,7 @@
 import bcrypt
 import re
 import urllib
-from flask import request, session, make_response, jsonify, redirect
+from flask import request, session, make_response, jsonify, redirect, g
 from flask_helpers import render_template
 from utils import timems, times, extract_bcrypt_rounds, is_testing_request, is_debug_mode, valid_email, is_heroku, mstoisostring
 import datetime
@@ -155,11 +155,11 @@ def login():
         body = request.json
         # Validations
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'username must be a string', 400
+            return g.auth_texts.get('username_invalid'), 400
         if not isinstance(body.get('password'), str):
-            return 'password must be a string', 400
+            return g.auth_texts.get('password_invalid'), 400
 
         # If username has an @-sign, then it's an email
         if '@' in body['username']:
@@ -167,10 +167,8 @@ def login():
         else:
             user = DATABASE.user_by_username(body['username'])
 
-        if not user:
-            return 'invalid username/password', 403
-        if not check_password(body['password'], user['password']):
-            return 'invalid username/password', 403
+        if not user or not check_password(body['password'], user['password']):
+            return g.auth_texts.get('invalid_username_password') + " " + g.auth_texts.get('no_account'), 403
 
         # If the number of bcrypt rounds has changed, create a new hash.
         new_hash = None
@@ -197,56 +195,56 @@ def login():
 
     @app.route('/auth/signup', methods=['POST'])
     def signup():
-        print("Er wordt een account aangemaakt!")
         body = request.json
-        print(body)
         # Validations, mandatory fields
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'username must be a string', 400
-        if '@' in body['username']:
-            return 'username cannot contain an @-sign', 400
-        if ':' in body['username']:
-            return 'username cannot contain a colon', 400
+            return g.auth_texts.get('username_invalid'), 400
+        if '@' in body['username'] or ':' in body['username']:
+            return g.auth_texts.get('username_special'), 400
         if len(body['username'].strip()) < 3:
-            return 'username must be at least three characters long', 400
+            return g.auth_texts.get('username_three'), 400
+        if not isinstance(body.get('email'), str) or not valid_email(body['email']):
+            return g.auth_texts.get('email_invalid'), 400
+        if not isinstance(body.get('mail_repeat'), str) or not valid_email(body['mail_repeat']):
+            return g.auth_texts.get('repeat_match_email'), 400
+        if body['email'] != body['mail_repeat']:
+            return g.auth_texts.get('repeat_match_email'), 400
         if not isinstance(body.get('password'), str):
-            return 'password must be a string', 400
+            return g.auth_texts.get('password_invalid'), 400
         if len(body['password']) < 6:
-            return 'password must be at least six characters long', 400
-        if not isinstance(body.get('email'), str):
-            return 'email must be a string', 400
-        if not valid_email(body['email']):
-            return 'email must be a valid email', 400
+            return g.auth_texts.get('password_six'), 400
+        if not isinstance(body.get('password_repeat'), str) or body['password'] != body['password_repeat']:
+            return g.auth_texts.get('repeat_match_password'), 400
+        if not isinstance(body.get('language'), str):
+            return g.auth_texts.get('language_invalid'), 400
+
         # Validations, optional fields
-        if 'country' in body:
-            if not body['country'] in countries:
-                return 'country must be a valid country', 400
         if 'birth_year' in body:
             if not isinstance(body.get('birth_year'), int) or body['birth_year'] <= 1900 or body['birth_year'] > datetime.datetime.now().year:
-                return 'birth_year must be a year between 1900 and ' + datetime.datetime.now().year, 400
-        if 'language' in body:
-            if not isinstance(body.get('language'), str):
-                return 'language must be a valid language', 400
+                return (g.auth_texts.get('year_invalid') + str(datetime.datetime.now().year)), 400
         if 'gender' in body:
             if body['gender'] != 'm' and body['gender'] != 'f' and body['gender'] != 'o':
-                return 'gender must be m/f/o', 400
-        if 'prog_experience' in body and body['prog_experience'] not in['yes', 'no']:
-            return 'If present, prog_experience must be "yes" or "no"', 400
+                return g.auth_texts.get('gender_invalid'), 400
+        if 'country' in body:
+            if not body['country'] in countries:
+                return g.auth_texts.get('country_invalid'), 400
+        if 'prog_experience' in body and body['prog_experience'] not in ['yes', 'no']:
+            return g.auth_texts.get('experience_invalid'), 400
         if 'experience_languages' in body:
             if not isinstance(body['experience_languages'], list):
-                return 'If present, experience_languages must be an array', 400
+                return g.auth_texts.get('experience_invalid'), 400
             for language in body['experience_languages']:
                 if language not in['scratch', 'other_block', 'python', 'other_text']:
-                    return 'Invalid language: ' + str(language), 400
+                    return g.auth_texts.get('programming_invalid'), 400
 
         user = DATABASE.user_by_username(body['username'].strip().lower())
         if user:
-            return 'username exists', 403
+            return g.auth_texts.get('exists_username'), 403
         email = DATABASE.user_by_email(body['email'].strip().lower())
         if email:
-            return 'email exists', 403
+            return g.auth_texts.get('exists_email'), 403
 
         hashed = hash(body['password'], make_salt())
 
@@ -344,25 +342,26 @@ def destroy(user):
     @app.route('/auth/change_password', methods=['POST'])
     @requires_login
     def change_password(user):
-
         body = request.json
-        if not isinstance(body, dict):
-            return 'body must be an object', 400
-        if not isinstance(body.get('old_password'), str):
-            return 'body.old_password must be a string', 400
-        if not isinstance(body.get( 'new_password'), str):
-            return 'body.new_password must be a string', 400
 
-        if len(body['new_password']) < 6:
-            return 'password must be at least six characters long', 400
+        if not isinstance(body, dict):
+            return g.auth_texts.get('ajax_error'), 400
+        if not isinstance(body.get('old_password'), str) or not isinstance(body.get('password'), str):
+            return g.auth_texts.get('password_invalid'), 400
+        if not isinstance(body.get( 'password_repeat'), str):
+            return g.auth_texts.get('repeat_match_password'), 400
+        if len(body['password']) < 6:
+            return g.auth_texts.get('password_six'), 400
+        if body['password'] != body['password_repeat']:
+            return g.auth_texts.get('repeat_match_password'), 400
 
         # The user object we got from 'requires_login' doesn't have the password, so look that up in the database
         user = DATABASE.user_by_username(user['username'])
 
         if not check_password(body['old_password'], user['password']):
-            return 'invalid username/password', 403
+            return g.auth_texts.get('password_invalid'), 403
 
-        hashed = hash(body['new_password'], make_salt())
+        hashed = hash(body['password'], make_salt())
 
         DATABASE.update_user(user['username'], {'password': hashed})
         # We are not updating the user in the Flask session, because we should not rely on the password in anyway.
@@ -374,35 +373,32 @@ def change_password(user):
     @app.route('/profile', methods=['POST'])
     @requires_login
     def update_profile(user):
-
         body = request.json
         if not isinstance(body, dict):
-            return 'body must be an object', 400
-        if 'email' in body:
-            if not isinstance(body.get('email'), str):
-                return 'body.email must be a string', 400
-            if not valid_email(body['email']):
-                return 'body.email must be a valid email', 400
-        if 'country' in body:
-            if not body['country'] in countries:
-                return 'body.country must be a valid country', 400
+            return g.auth_texts.get('ajax_error'), 400
+        if not isinstance(body.get('email'), str) or not valid_email(body['email']):
+            return g.auth_texts.get('email_invalid'), 400
+        if not isinstance(body.get('language'), str):
+            return g.auth_texts.get('language_invalid'), 400
+
+        # Validations, optional fields
         if 'birth_year' in body:
             if not isinstance(body.get('birth_year'), int) or body['birth_year'] <= 1900 or body['birth_year'] > datetime.datetime.now().year:
-                return 'birth_year must be a year between 1900 and ' + str(datetime.datetime.now().year), 400
-        if 'language' in body:
-            if not isinstance(body.get('language'), str):
-                return 'language must be a valid language', 400
+                return g.auth_texts.get('year_invalid') + str(datetime.datetime.now().year), 400
         if 'gender' in body:
             if body['gender'] != 'm' and body['gender'] != 'f' and body['gender'] != 'o':
-                return 'body.gender must be m/f/o', 400
-        if 'prog_experience' in body and body['prog_experience'] not in['yes', 'no']:
-            return 'If present, prog_experience must be "yes" or "no"', 400
+                return g.auth_texts.get('gender_invalid'), 400
+        if 'country' in body:
+            if not body['country'] in countries:
+                return g.auth_texts.get('country_invalid'), 400
+        if 'prog_experience' in body and body['prog_experience'] not in ['yes', 'no']:
+            return g.auth_texts.get('experience_invalid'), 400
         if 'experience_languages' in body:
             if not isinstance(body['experience_languages'], list):
-                return 'If present, experience_languages must be an array', 400
+                return g.auth_texts.get('experience_invalid'), 400
             for language in body['experience_languages']:
                 if language not in['scratch', 'other_block', 'python', 'other_text']:
-                    return 'Invalid language: ' + str(language), 400
+                    return g.auth_texts.get('programming_invalid'), 400
 
         resp = {}
         if 'email' in body:
@@ -410,7 +406,7 @@ def update_profile(user):
             if email != user['email']:
                 exists = DATABASE.user_by_email(email)
                 if exists:
-                    return 'email exists', 403
+                    return g.auth_texts.get('exists_email'), 403
                 token = make_salt()
                 hashed_token = hash(token, make_salt())
                 DATABASE.update_user(user['username'], {'email': email, 'verification_pending': hashed_token})
@@ -439,10 +435,12 @@ def update_profile(user):
                    updates[field] = None
                else:
                    updates[field] = body[field]
+           else:
+               updates[field] = None
 
         if updates:
             DATABASE.update_user(username, updates)
-
+        remember_current_user(DATABASE.user_by_username(user['username']))
         return jsonify(resp)
 
     @app.route('/profile', methods=['GET'])
@@ -469,9 +467,9 @@ def recover():
         body = request.json
         # Validations
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'body.username must be a string', 400
+            return g.auth_texts.get('username_invalid'), 400
 
         # If username has an @-sign, then it's an email
         if '@' in body['username']:
@@ -480,7 +478,7 @@ def recover():
             user = DATABASE.user_by_username(body['username'].strip().lower())
 
         if not user:
-            return 'invalid username', 403
+            return g.auth_texts.get('username_invalid'), 403
 
         token = make_salt()
         hashed = hash(token, make_salt())
@@ -499,23 +497,24 @@ def reset():
         body = request.json
         # Validations
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'body.username must be a string', 400
+            return g.auth_texts.get('username_invalid'), 400
         if not isinstance(body.get('token'), str):
-            return 'body.token must be a string', 400
+            return g.auth_texts.get('token_invalid'), 400
         if not isinstance(body.get('password'), str):
-            return 'body.password be a string', 400
-
+            return g.auth_texts.get('password_invalid'), 400
         if len(body['password']) < 6:
-            return 'password must be at least six characters long', 400
+            return g.auth_texts.get('password_six'), 400
+        if not isinstance(body.get('password_repeat'), str) or body['password'] != body['password_repeat']:
+            return g.auth_texts.get('repeat_match_password'), 400
 
         # There's no need to trim or lowercase username, because it should come within a link prepared by the app itself and not inputted manually by the user.
         token = DATABASE.get_token(body['username'])
         if not token:
-            return 'invalid username/token', 403
+            return g.auth_texts.get('token_invalid'), 403
         if not check_password(body['token'], token['token']):
-            return 'invalid username/token', 403
+            return g.auth_texts.get('token_invalid'), 403
 
         hashed = hash(body['password'], make_salt())
         token = DATABASE.forget_token(body['username'])
@@ -539,16 +538,16 @@ def mark_as_teacher():
 
         # Validations
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'body.username must be a string', 400
+            return g.auth_texts.get('username_invalid'), 400
         if not isinstance(body.get('is_teacher'), bool):
-            return 'body.is_teacher must be boolean', 400
+            return g.auth_texts.get('teacher_invalid'), 400
 
         user = DATABASE.user_by_username(body['username'].strip().lower())
 
         if not user:
-            return 'invalid username', 400
+            return g.auth_texts.get('username_invalid'), 400
 
         is_teacher_value = 1 if body['is_teacher'] else 0
         update_is_teacher(user, is_teacher_value)
@@ -565,18 +564,16 @@ def change_user_email():
 
         # Validations
         if not isinstance(body, dict):
-            return 'body must be an object', 400
+            return g.auth_texts.get('ajax_error'), 400
         if not isinstance(body.get('username'), str):
-            return 'body.username must be a string', 400
-        if not isinstance(body.get('email'), str):
-            return 'body.email must be a string', 400
-        if not valid_email(body['email']):
-            return 'email must be a valid email', 400
+            return g.auth_texts.get('username_invalid'), 400
+        if not isinstance(body.get('email'), str) or not valid_email(body['email']):
+            return g.auth_texts.get('email_invalid'), 400
 
         user = DATABASE.user_by_username(body['username'].strip().lower())
 
         if not user:
-            return 'invalid username', 400
+            return g.auth_texts.get('email_invalid'), 400
 
         token = make_salt()
         hashed_token = hash(token, make_salt())


Test patch:
diff --git a/tests_e2e.py b/tests_e2e.py
--- a/tests_e2e.py
+++ b/tests_e2e.py
@@ -93,7 +93,8 @@ def assert_user_exists(self, username):
 
         if username in USERS:
             return USERS[username]
-        body = {'username': username, 'email': username + '@hedy.com', 'password': 'foobar'}
+        body = {'username': username, 'email': username + '@hedy.com', 'mail_repeat': username + '@hedy.com',
+                'language': 'nl', 'password': 'foobar', 'password_repeat': 'foobar'}
         response = request('post', 'auth/signup', {}, body, cookies=self.user_cookies[username])
 
         # It might sometimes happen that by the time we attempted to create the user, another test did it already.
@@ -266,7 +267,8 @@ def test_invalid_signups(self):
     def test_signup(self):
         # GIVEN a valid username and signup body
         username = self.make_username()
-        user = {'username': username, 'email': username + '@hedy.com', 'password': 'foobar'}
+        user = {'username': username, 'email': username + '@hedy.com', 'mail_repeat': username + '@hedy.com',
+                'password': 'foobar', 'password_repeat': 'foobar', 'language': 'nl'}
 
         # WHEN signing up a new user
         # THEN receive an OK response code from the server
@@ -392,15 +394,17 @@ def test_invalid_change_password(self):
         # GIVEN a logged in user
         self.given_user_is_logged_in()
 
-        # WHEN attempting signups with invalid bodies
+        # WHEN attempting change password with invalid bodies
         invalid_bodies = [
             '',
             [],
             {},
             {'old_password': 123456},
             {'old_password': 'pass1'},
-            {'old_password': 'pass1', 'new_password': 123456},
-            {'old_password': 'pass1', 'new_password': 'short'},
+            {'old_password': 'pass1', 'password': 123456},
+            {'old_password': 'pass1', 'password': 'short'},
+            {'old_password': 'pass1', 'password': 123456, 'password_repeat': 'panda'},
+            {'old_password': 'pass1', 'password_repeat': 'panda'},
         ]
 
         for invalid_body in invalid_bodies:
@@ -409,10 +413,8 @@ def test_invalid_change_password(self):
 
         # WHEN attempting to change password without sending the correct old password
         # THEN receive an invalid response code from the server
-        self.post_data('auth/change_password', {
-            'old_password': 'password',
-            'new_password': self.user['password'] + 'foo'
-        }, expect_http_code=403)
+        body = {'old_password': 'pass1', 'password': '123456', 'password_repeat': '123456'}
+        self.post_data('auth/change_password', body, expect_http_code=403)
 
     def test_change_password(self):
         # GIVEN a logged in user
@@ -421,7 +423,8 @@ def test_change_password(self):
         # WHEN attempting to change the user's password
         new_password = 'pas1234'
         # THEN receive an OK response code from the server
-        self.post_data('auth/change_password', {'old_password': self.user['password'], 'new_password': 'pas1234'})
+        self.post_data('auth/change_password', {'old_password': self.user['password'],
+                                                'password': 'pas1234', 'password_repeat': 'pas1234'})
 
         # WHEN attempting to login with old password
         # THEN receive a forbidden response code from the server
@@ -496,7 +499,7 @@ def test_profile_modify(self):
         }
 
         for key in profile_changes:
-            body = {}
+            body = {'email': self.user['email'], 'language': self.user['language']}
             body[key] = profile_changes[key]
             # THEN receive an OK response code from the server
             self.post_data('profile', body)
@@ -510,7 +513,7 @@ def test_profile_modify(self):
         # (we check email change separately since it involves a flow with a token)
         # THEN receive an OK response code from the server
         new_email = self.username + '@newhedy.com'
-        body = self.post_data('profile', {'email': new_email})
+        body = self.post_data('profile', {'email': new_email, 'language': self.user['language']})
 
         # THEN confirm that the server replies with an email verification token
         self.assertIsInstance(body['token'], str)
@@ -562,7 +565,9 @@ def test_invalid_reset_password(self):
             {'username': 'foobar', 'token': 1},
             {'username': 'foobar', 'token': 'some'},
             {'username': 'foobar', 'token': 'some', 'password': 1},
-            {'username': 'foobar', 'token': 'some', 'password': 'short'}
+            {'username': 'foobar', 'token': 'some', 'password': 'short'},
+            {'username': 'foobar', 'token': 'some', 'password': 'short', 'password_repeat': 123},
+            {'username': 'foobar', 'token': 'some', 'password_repeat': 'panda123'}
         ]
 
         for invalid_body in invalid_bodies:
@@ -571,7 +576,8 @@ def test_invalid_reset_password(self):
 
         # WHEN attempting a password reset with an invalid token
         # THEN receive a forbidden response code from the server
-        self.post_data('auth/reset', {'username': self.username, 'password': '123456', 'token': 'foobar'}, expect_http_code=403)
+        self.post_data('auth/reset', {'username': self.username, 'password': '123456',
+                                      'password_repeat': '123456', 'token': 'foobar'}, expect_http_code=403)
 
     def test_reset_password(self):
         # GIVEN an existing user
@@ -581,7 +587,8 @@ def test_reset_password(self):
         new_password = 'pas1234'
         recover_token = self.post_data('auth/recover', {'username': self.username})['token']
         # THEN receive an OK response code from the server
-        self.post_data('auth/reset', {'username': self.username, 'password': new_password, 'token': recover_token})
+        self.post_data('auth/reset', {'username': self.username, 'password': new_password,
+                                      'password_repeat': new_password, 'token': recover_token})
 
         # WHEN attempting a login with the new password
         # THEN receive an OK response code from the server