diff --git a/app/forms/admin.py b/app/forms/admin.py index 04ae985e..bc3b7772 100644 --- a/app/forms/admin.py +++ b/app/forms/admin.py @@ -3,9 +3,26 @@ from flask_wtf import FlaskForm from wtforms import PasswordField, StringField from wtforms.validators import DataRequired, EqualTo, Length, Optional, Regexp +from app.forms.validators import ( + USERNAME_ALLOWED_CHARS_MESSAGE, + USERNAME_LENGTH_MESSAGE, + USERNAME_MAX_LENGTH, + USERNAME_MIN_LENGTH, + USERNAME_PATTERN, + strip_filter, +) + _username_validators = [ DataRequired(), - Length(min=3, max=15, message=str(_l("Username must be 3 to 15 characters."))), + Length( + min=USERNAME_MIN_LENGTH, + max=USERNAME_MAX_LENGTH, + message=str(_l(USERNAME_LENGTH_MESSAGE)), + ), + Regexp( + USERNAME_PATTERN, + message=str(_l(USERNAME_ALLOWED_CHARS_MESSAGE)), + ), ] _password_validators = [ DataRequired(), @@ -22,7 +39,9 @@ _password_validators = [ class AdminCreateForm(FlaskForm): - username = StringField(str(_l("Username")), validators=_username_validators) + username = StringField( + str(_l("Username")), filters=[strip_filter], validators=_username_validators + ) password = PasswordField(str(_l("Password")), validators=_password_validators) confirm = PasswordField( str(_l("Confirm password")), @@ -34,7 +53,9 @@ class AdminCreateForm(FlaskForm): class AdminUpdateForm(FlaskForm): - username = StringField(str(_l("Username")), validators=_username_validators) + username = StringField( + str(_l("Username")), filters=[strip_filter], validators=_username_validators + ) # New password can be left blank (unchanged) password = PasswordField( diff --git a/app/forms/join.py b/app/forms/join.py index 8b9829c1..73e9492d 100644 --- a/app/forms/join.py +++ b/app/forms/join.py @@ -2,14 +2,33 @@ from flask_wtf import FlaskForm from wtforms import PasswordField, StringField from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp +from app.forms.validators import ( + USERNAME_ALLOWED_CHARS_MESSAGE, + USERNAME_LENGTH_MESSAGE, + USERNAME_MAX_LENGTH, + USERNAME_MIN_LENGTH, + USERNAME_PATTERN, + strip_filter, +) + class JoinForm(FlaskForm): username = StringField( "Username", - validators=[DataRequired()], + filters=[strip_filter], + validators=[ + DataRequired(), + Length( + min=USERNAME_MIN_LENGTH, + max=USERNAME_MAX_LENGTH, + message=USERNAME_LENGTH_MESSAGE, + ), + Regexp(USERNAME_PATTERN, message=USERNAME_ALLOWED_CHARS_MESSAGE), + ], ) email = StringField( "Email", + filters=[strip_filter], validators=[DataRequired(), Email()], ) password = PasswordField( @@ -32,6 +51,7 @@ class JoinForm(FlaskForm): ) code = StringField( "Invite Code", + filters=[strip_filter], validators=[DataRequired(), Length(min=6, max=10)], render_kw={"minlength": 6, "maxlength": 10}, ) diff --git a/app/forms/setup.py b/app/forms/setup.py index 0cb4c3e4..c19dc6f0 100644 --- a/app/forms/setup.py +++ b/app/forms/setup.py @@ -2,14 +2,29 @@ from flask_wtf import FlaskForm from wtforms import PasswordField, StringField from wtforms.validators import DataRequired, EqualTo, Length, Regexp +from app.forms.validators import ( + USERNAME_ALLOWED_CHARS_MESSAGE, + USERNAME_LENGTH_MESSAGE, + USERNAME_MAX_LENGTH, + USERNAME_MIN_LENGTH, + USERNAME_PATTERN, + strip_filter, +) + class AdminAccountForm(FlaskForm): username = StringField( "Username", validators=[ DataRequired(), - Length(min=3, max=15, message="Username must be 3 to 15 characters."), + Length( + min=USERNAME_MIN_LENGTH, + max=USERNAME_MAX_LENGTH, + message=USERNAME_LENGTH_MESSAGE, + ), + Regexp(USERNAME_PATTERN, message=USERNAME_ALLOWED_CHARS_MESSAGE), ], + filters=[strip_filter], ) password = PasswordField( "Password", diff --git a/app/forms/validators.py b/app/forms/validators.py new file mode 100644 index 00000000..43c3a0fc --- /dev/null +++ b/app/forms/validators.py @@ -0,0 +1,21 @@ +""" +Shared form validation constants and filters. +""" + +from typing import Any + +USERNAME_PATTERN = r"^[\w'.-]+$" +USERNAME_MIN_LENGTH = 3 +USERNAME_MAX_LENGTH = 15 +USERNAME_LENGTH_MESSAGE = "Username must be 3 to 15 characters." +USERNAME_ALLOWED_CHARS_MESSAGE = ( + "Username can contain letters, numbers, dashes (-), underscores (_), " + "apostrophes ('), and periods (.)." +) + + +def strip_filter(value: Any): + """Trim leading/trailing whitespace from string inputs before validation.""" + if isinstance(value, str): + return value.strip() + return value diff --git a/tests/test_join_form_validation.py b/tests/test_join_form_validation.py new file mode 100644 index 00000000..fdb09ed6 --- /dev/null +++ b/tests/test_join_form_validation.py @@ -0,0 +1,75 @@ +from app.forms.join import JoinForm +from app.forms.setup import AdminAccountForm +from app.forms.validators import USERNAME_ALLOWED_CHARS_MESSAGE + + +def _join_form_payload(**overrides): + base = { + "username": "validuser", + "email": "user@example.com", + "password": "ValidPass1", + "confirm_password": "ValidPass1", + "code": "ABCDEF", + } + base.update(overrides) + return base + + +def _admin_form_payload(**overrides): + base = { + "username": "adminuser", + "password": "ValidPass1", + "confirm": "ValidPass1", + } + base.update(overrides) + return base + + +def test_join_form_rejects_spaces_in_username(app): + with app.test_request_context( + method="POST", data=_join_form_payload(username="invalid user") + ): + form = JoinForm() + + assert not form.validate() + assert USERNAME_ALLOWED_CHARS_MESSAGE in form.username.errors + + +def test_join_form_strips_trailing_whitespace(app): + with app.test_request_context( + method="POST", data=_join_form_payload(username="validuser ") + ): + form = JoinForm() + + assert form.validate() + assert form.username.data == "validuser" + + +def test_join_form_rejects_invalid_symbols(app): + with app.test_request_context( + method="POST", data=_join_form_payload(username="bad$user") + ): + form = JoinForm() + + assert not form.validate() + assert USERNAME_ALLOWED_CHARS_MESSAGE in form.username.errors + + +def test_admin_account_form_strips_username_whitespace(app): + with app.test_request_context( + method="POST", data=_admin_form_payload(username=" adminuser ") + ): + form = AdminAccountForm() + + assert form.validate() + assert form.username.data == "adminuser" + + +def test_admin_account_form_rejects_invalid_username(app): + with app.test_request_context( + method="POST", data=_admin_form_payload(username="admin user") + ): + form = AdminAccountForm() + + assert not form.validate() + assert USERNAME_ALLOWED_CHARS_MESSAGE in form.username.errors