Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions onadata/apps/api/tests/viewsets/test_user_profile_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,314 @@ def test_twitter_username_validation(self):
user = User.objects.get(username="deno")
self.assertTrue(user.is_active)

def test_email_username(self):
data = {
"username": "[email protected]",
"name": "Dennis deno",
"email": "[email protected]",
"city": "Denoville",
"country": "US",
"organization": "Dono Inc.",
"website": "deno.com",
"twitter": "denoerama",
"require_auth": False,
"password": "denodeno",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "[email protected]")

data = {
"username": "columbia.txt",
"name": "Dennis deno",
"email": "[email protected]",
"city": "Denoville",
"country": "US",
"organization": "Dono Inc.",
"website": "deno.com",
"twitter": "denoerama",
"require_auth": False,
"password": "denodeno",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "columbia.txt")

def test_username_with_blocked_file_extensions(self):
"""Test that usernames ending with blocked file extensions are rejected"""
blocked_extensions = ["json", "csv", "xls", "xlsx", "kml"]

for ext in blocked_extensions:
data = {
"username": f"username.{ext}",
"name": "Test User",
"email": f"test{ext}@example.com",
"city": "TestCity",
"country": "US",
"organization": "Test Inc.",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 400)
self.assertIn("username", response.data)

def test_username_with_forward_slash(self):
"""Test that usernames with forward slashes are rejected"""
invalid_usernames = ["user/name", "path/to/user", "test/"]

for username in invalid_usernames:
data = {
"username": username,
"name": "Test User",
"email": f"{username.replace('/', '')}@example.com",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 400)
self.assertIn("username", response.data)

def test_username_with_special_characters(self):
"""Test that valid usernames with special characters are accepted"""
valid_usernames = [
"user-name",
"user_name",
"user.name",
"user.middle.name",
"user123",
"user-name_123.test",
]

for idx, username in enumerate(valid_usernames):
data = {
"username": username,
"name": "Test User",
"email": f"test{idx}@example.com",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(
response.status_code, 201, f"Failed for username: {username}"
)
self.assertEqual(response.data["username"], username)

def test_email_like_usernames(self):
"""Test various email-like username formats"""
# Email with subdomain
data = {
"username": "[email protected]",
"name": "Test User",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "[email protected]")

# Email with plus sign
data = {
"username": "[email protected]",
"name": "Test User Plus",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "[email protected]")

def test_username_case_sensitivity(self):
"""Test that email usernames are normalized to lowercase"""
data = {
"username": "[email protected]",
"name": "Test User",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 201)
# Username should be normalized to lowercase
self.assertEqual(response.data["username"], "[email protected]")

def test_username_boundary_cases(self):
"""Test boundary cases for username validation"""
# Username ending with .tx (not blocked)
data = {
"username": "data.tx",
"name": "Test User TX",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "data.tx")

# Username like data.jsons (not exactly .json)
data = {
"username": "data.jsons",
"name": "Test User JSONS",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "data.jsons")

# Username with dots but not ending in blocked extension
data = {
"username": "test.user.name",
"name": "Test User Dots",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "test.user.name")

def test_username_with_numbers_and_special_chars(self):
"""Test usernames with mixed numbers and special characters"""
data = {
"username": "test123-user_456.name",
"name": "Test Mixed User",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["username"], "test123-user_456.name")

def test_blocked_extension_case_insensitive(self):
"""Test that blocked extensions work case-insensitively"""
# Note: The regex is case-sensitive, but Django normalizes usernames to lowercase
# So username.JSON becomes username.json before validation
data = {
"username": "Username.JSON",
"name": "Test User JSON Upper",
"email": "[email protected]",
"city": "TestCity",
"country": "US",
"password": "testpass123",
"is_org": False,
}
request = self.factory.post(
"/api/v1/profiles",
data=json.dumps(data),
content_type="application/json",
**self.extra,
)
response = self.view(request)

# Should be rejected after normalization to lowercase
self.assertEqual(response.status_code, 400)
self.assertIn("username", response.data)

def test_put_patch_method_on_names(self):
data = _profile_data()
# create profile
Expand Down
1 change: 1 addition & 0 deletions onadata/apps/api/viewsets/user_profile_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ class UserProfileViewSet(
lookup_field = "user"
permission_classes = [UserProfilePermissions]
filter_backends = (filters.UserProfileFilter, OrderingFilter)
lookup_value_regex = r"(?:[^/.]|\.(?!(?:json|csv|xls|xlsx|kml)(?:/|$)))+"
ordering = ("user__username",)

def get_object(self, queryset=None):
Expand Down
9 changes: 6 additions & 3 deletions onadata/apps/api/viewsets/user_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
"""
Users /users API endpoint.
"""

from django.conf import settings
from django.contrib.auth import get_user_model

from rest_framework import filters
from rest_framework.generics import get_object_or_404
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework import filters

from onadata.apps.api import permissions
from onadata.apps.api.tools import get_baseviewset_class
from onadata.libs.filters import UserNoOrganizationsFilter
from onadata.libs.mixins.authenticate_header_mixin import AuthenticateHeaderMixin
from onadata.libs.mixins.cache_control_mixin import CacheControlMixin
from onadata.libs.mixins.etags_mixin import ETagsMixin
from onadata.libs.serializers.user_serializer import UserSerializer
from onadata.apps.api import permissions
from onadata.apps.api.tools import get_baseviewset_class

BaseViewset = get_baseviewset_class() # pylint: disable=invalid-name
User = get_user_model()
Expand All @@ -37,6 +39,7 @@ class UserViewSet(
)
serializer_class = UserSerializer
lookup_field = "username"
lookup_value_regex = r"(?:[^/.]|\.(?!(?:json|csv|xls|xlsx|kml)(?:/|$)))+"
permission_classes = [permissions.UserViewSetPermissions]
filter_backends = (
filters.SearchFilter,
Expand Down
5 changes: 3 additions & 2 deletions onadata/apps/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""
forms module.
"""

import os
import random
import re
Expand Down Expand Up @@ -204,7 +205,7 @@ class RegistrationFormUserProfile(RegistrationFormUniqueEmail, UserProfileFormRe
RESERVED_USERNAMES = settings.RESERVED_USERNAMES
username = forms.CharField(widget=forms.TextInput(), max_length=30)
email = forms.EmailField(widget=forms.TextInput())
legal_usernames_re = re.compile(r"^\w+$")
legal_usernames_re = re.compile(r"^(?!.*\.(?:json|csv|xls|xlsx|kml)$)[^/]+$")

def clean_username(self):
"""
Expand All @@ -216,7 +217,7 @@ def clean_username(self):
raise forms.ValidationError(
_(f"{username} is a reserved name, please choose another")
)
if not self.legal_usernames_re.search(username):
if not self.legal_usernames_re.match(username):
raise forms.ValidationError(
_("username may only contain alpha-numeric characters and underscores")
)
Expand Down
Loading