feat: enhance username validation and trimming across forms

This commit is contained in:
Matthieu B
2025-11-04 19:02:56 +01:00
parent da8d008398
commit 3a294a02f0
5 changed files with 157 additions and 5 deletions

View File

@@ -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(

View File

@@ -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},
)

View File

@@ -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",

21
app/forms/validators.py Normal file
View File

@@ -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

View File

@@ -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