Snapshot: alpha-2026-9

This commit is contained in:
j3d1 2026-09-02 21:40:28 +02:00
parent 9acf5a97e2
commit d00b5c7961
241 changed files with 85546 additions and 2409 deletions

View file

@ -1,6 +1,14 @@
from django.contrib import admin
from django.contrib.auth.models import Group as DjangoPermissionGroup
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
# django.contrib.auth's own permission-groups model is unused (this app authenticates via
# signature_auth, not Django's built-in permission system) and otherwise collides in the admin
# sidebar with our own Group (the actor-type group), which is registered below under the same
# "Groups" label.
admin.site.unregister(DjangoPermissionGroup)
class ToolshedUserAdmin(admin.ModelAdmin):
@ -8,6 +16,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
class AccountPreferenceAdmin(admin.ModelAdmin):
list_display = ('user', 'key', 'value')
search_fields = ('user__username', 'key')
class KnownIdentityAdmin(admin.ModelAdmin):
list_display = ('username', 'domain', 'public_key')
search_fields = ('username', 'domain', 'public_key')
@ -23,7 +36,36 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'domain', 'get_members')
search_fields = ('name', 'domain', 'members__username')
@admin.display(description='Members')
def get_members(self, obj):
return ', '.join(str(member) for member in obj.members.all())
class GroupInviteAdmin(admin.ModelAdmin):
list_display = ('secret', 'group', 'invitee_username', 'invitee_domain')
search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain')
class GroupInviteIncomingAdmin(admin.ModelAdmin):
list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'group_name', 'group_domain', 'created_at')
search_fields = ('user__username', 'group_name', 'group_domain')
admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)
admin.site.register(Group, GroupAdmin)
admin.site.register(GroupInvite, GroupInviteAdmin)
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
admin.site.register(GroupMembership, GroupMembershipAdmin)

View file

@ -9,8 +9,11 @@ from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.response import Response
from authentication.models import ToolshedUser
from authentication.signature_auth import SignatureAuthenticationLocal
from authentication.models import ToolshedUser, AccountPreference
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
split_userhandle_or_throw
from files.models import File
from files.serializers import FileSerializer
from hostadmin.models import Domain
router = routers.SimpleRouter()
@ -53,15 +56,71 @@ class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsAdminUser]
@api_view(['GET'])
@api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request):
"""Get or update the authenticated local user's own account info; only the account owner may
call this on their own home server (see getUserProfile for viewing a friend's public profile)."""
user = request.user
if request.method == 'PATCH':
old_file = user.profile_picture
if 'profile_picture' in request.data:
profile_picture = request.data.get('profile_picture')
if profile_picture is None:
user.profile_picture = None
elif type(profile_picture) == dict:
serializer = FileSerializer(data=profile_picture)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
user.profile_picture = serializer.save()
else:
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
elif 'profile_picture_id' in request.data:
profile_picture_id = request.data.get('profile_picture_id')
if profile_picture_id is None:
user.profile_picture = None
else:
try:
user.profile_picture = File.objects.get(id=profile_picture_id)
except File.DoesNotExist:
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save()
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \
and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0:
old_file.file.delete(save=False)
old_file.delete()
return Response({
'username': user.username,
'domain': user.domain,
'email': user.email
'email': user.email,
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def getUserProfile(request, handle):
"""Get another local user's public profile by handle, e.g. so a friend can look up an avatar;
caller must be a friend of that user (or the user itself, signing with their own known
identity rather than local credentials)."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=400)
try:
target = ToolshedUser.objects.get(username=username, domain=domain)
except ToolshedUser.DoesNotExist:
return Response(status=404)
if target not in request.user.friends_or_self():
return Response(status=403)
return Response({
'username': target.username,
'domain': target.domain,
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
})
@ -99,11 +158,40 @@ def registerUser(request):
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preferences(request):
"""Get or bulk-upsert the authenticated user's account-level preferences: GET returns the
current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified
keys untouched."""
if request.method == 'PUT':
if not isinstance(request.data, dict):
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
for key, value in request.data.items():
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
return Response(preferences)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preference_detail(request, key):
"""Reset a single account-level preference back to its default by deleting it."""
AccountPreference.objects.filter(user=request.user, key=key).delete()
return Response(status=204)
router.register(r'users', UserViewSet)
urlpatterns = [
path('', include(router.urls)),
path('user/', getUserInfo),
path('user/<str:handle>/', getUserProfile),
path('register/', registerUser),
path('token/', UserAuthToken.as_view()),
path('self/preferences/', account_preferences),
path('self/preferences/<str:key>/', account_preference_detail),
]

View file

@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0001_initial'),
('authentication', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='toolsheduser',
name='profile_picture',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='profile_picture_users', to='files.file'),
),
]

View file

@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_toolsheduser_profile_picture'),
]
operations = [
migrations.CreateModel(
name='AccountPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.JSONField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
to='authentication.toolsheduser')),
],
options={
'unique_together': {('user', 'key')},
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2026-08-09 13:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_accountpreference'),
]
operations = [
migrations.AlterField(
model_name='accountpreference',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View file

@ -0,0 +1,56 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_accountpreference_id'),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.CharField(max_length=255)),
('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')),
],
),
migrations.CreateModel(
name='GroupInviteIncoming',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('inviter_username', models.CharField(max_length=255)),
('inviter_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GroupInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('invitee_username', models.CharField(max_length=255)),
('invitee_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')),
],
),
migrations.AddIndex(
model_name='group',
index=models.Index(fields=['name', 'domain'], name='group_idx'),
),
migrations.AlterUniqueTogether(
name='group',
unique_together={('name', 'domain')},
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.2 on 2026-08-26 16:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
]
operations = [
migrations.CreateModel(
name='GroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'group_name', 'group_domain')},
},
),
]

View file

@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
domain = models.CharField(max_length=255, default='localhost')
private_key = models.CharField(max_length=255)
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
related_name='profile_picture_users')
objects = ToolshedUserManager()
class Meta:
@ -111,6 +113,20 @@ class ToolshedUser(AbstractUser):
return self.public_identity.public_key
class AccountPreference(models.Model):
"""A single account-level (server-synced, cross-device) preference as a key/value pair;
device-level preferences are intentionally *not* stored here."""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
key = models.CharField(max_length=255)
value = models.JSONField()
class Meta:
unique_together = ('user', 'key')
def __str__(self):
return f"{self.user}: {self.key}"
class FriendRequestOutgoing(models.Model):
secret = models.CharField(max_length=255)
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
@ -125,3 +141,62 @@ class FriendRequestIncoming(models.Model):
befriender_public_key = models.CharField(max_length=255)
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class Group(models.Model):
name = models.CharField(max_length=255)
domain = models.CharField(max_length=255)
members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups')
class Meta:
unique_together = ('name', 'domain')
indexes = [
models.Index(fields=['name', 'domain'], name='group_idx'),
]
def __str__(self):
return f"#{self.name}@{self.domain}"
def is_member(self, identity):
return self.members.filter(pk=identity.pk).exists()
class GroupInvite(models.Model):
"""A pending invite tracked on the group's own home backend, checked when the invitee's accept
request arrives (mirror: GroupInviteIncoming on the invitee's backend; see
docs/design-in-progress/groups-mvp.md)."""
secret = models.CharField(max_length=255)
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invitee_username = models.CharField(max_length=255)
invitee_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class GroupInviteIncoming(models.Model):
secret = models.CharField(max_length=255)
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
inviter_username = models.CharField(max_length=255)
inviter_domain = models.CharField(max_length=255)
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class GroupMembership(models.Model):
"""A pointer kept on a member's own home backend, recording that the local user is a member
of a group that may be hosted here or on a remote domain (mirrors FriendRequestIncoming's
role: independently recorded on the member's own side, not just the group's authoritative
backend). Written once the invitee's accept request against the group's home backend has
succeeded (see GroupInvitesIncomingAccept), analogous to how a friendship is independently
recorded on both sides via KnownIdentity.friends rather than only on one. See
docs/design-in-progress/groups-mvp.md's 'Known limitation'."""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_memberships')
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'group_name', 'group_domain')
def __str__(self):
return f"{self.user} MEMBER_OF #{self.group_name}@{self.group_domain}"

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from authentication.models import ToolshedUser
from authentication.models import ToolshedUser, Group
class OwnerSerializer(serializers.ReadOnlyField):
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
def to_representation(self, value):
return value.username + '@' + value.domain
class GroupOwnerSerializer(serializers.ReadOnlyField):
class Meta:
model = Group
fields = ['name', 'domain']
def to_representation(self, value):
return f"#{value.name}@{value.domain}"

View file

@ -2,7 +2,7 @@ from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
from rest_framework import authentication
from authentication.models import KnownIdentity, ToolshedUser
from authentication.models import Group, KnownIdentity, ToolshedUser
def split_userhandle_or_throw(userhandle):
@ -16,6 +16,27 @@ def split_userhandle_or_throw(userhandle):
return username, domain
def split_grouphandle_or_throw(grouphandle):
if not grouphandle.startswith('#'):
raise ValueError('Group handle must be in the format #name@domain')
return split_userhandle_or_throw(grouphandle[1:])
def resolve_owner_handle(handle):
"""Resolves a "user@domain" or "+name@domain" handle to (owner_user, owner_group), exactly one set. Raises ValueError if the handle doesn't parse at all (no '@'); returns (None, None) if it parses but names nothing that exists."""
is_group = handle.startswith('+')
name, domain = split_userhandle_or_throw(handle[1:] if is_group else handle)
if is_group:
try:
return None, Group.objects.get(name=name, domain=domain)
except Group.DoesNotExist:
return None, None
try:
return ToolshedUser.objects.get(username=name, domain=domain), None
except ToolshedUser.DoesNotExist:
return None, None
def verify_request(request, raw_request_body):
authentication_header = request.META.get('HTTP_AUTHORIZATION')
@ -74,6 +95,32 @@ def verify_incoming_friend_request(request, raw_request_body):
return False
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
"""Self-certifying verifier for the group invite/accept dance. See
docs/implementation.md#group-invite-and-accept-self-certifying-verification."""
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
except ValueError:
return False
try:
claimed_handle = request.data[handle_field]
claimed_key = request.data[key_field]
except KeyError:
return False
if not claimed_handle or not claimed_key:
return False
if username + "@" + domain != claimed_handle:
return False
if len(claimed_key) != 64:
return False
verify_key = VerifyKey(bytes.fromhex(claimed_key))
try:
verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex))
return True
except BadSignatureError:
return False
def authenticate_request_against_known_identities(request, raw_request_body):
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
@ -106,11 +153,17 @@ def authenticate_request_against_local_users(request, raw_request_body):
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
return authenticate_request_against_known_identities(
request, request.body.decode('utf-8')), None
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
# Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead
# of treating the request as authenticated with an empty user.
if identity is None:
return None
return identity, None
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
def authenticate(self, request):
return authenticate_request_against_local_users(
request, request.body.decode('utf-8')), None
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
if user is None:
return None
return user, None

View file

@ -3,7 +3,7 @@ import json
from django.test import TestCase, Client
from nacl.encoding import HexEncoder
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from hostadmin.models import Domain
from nacl.signing import SigningKey
@ -86,3 +86,9 @@ class UserTestMixin:
domain=self.f['example_com'].name)
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
class GroupTestMixin:
def prepare_groups(self):
self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name)
self.f['group1'].members.add(self.f['local_user1'].public_identity)

View file

@ -1,4 +1,5 @@
import json
import base64
from django.test import Client, RequestFactory
from nacl.encoding import HexEncoder
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
from files.models import File
class AuthorizationTestCase(ToolshedTestCase):
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertEqual(reply.json()['email'], 'test1@abc.de')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info2(self):
target = "/auth/user/"
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info_patch_profile_picture(self):
content = base64.b64encode(b'user-profile-image').decode('utf-8')
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
'profile_picture': {
'data': content,
'mime_type': 'image/png'
}
})
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.json()['profile_picture'])
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
self.assertEqual(File.objects.count(), 1)
self.f['local_user1'].refresh_from_db()
self.assertIsNotNone(self.f['local_user1'].profile_picture)
def test_user_info_patch_profile_picture_clear(self):
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
self.f['local_user1'].profile_picture = test_file
self.f['local_user1'].save()
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
self.assertEqual(reply.status_code, 200)
self.assertIsNone(reply.json()['profile_picture'])
self.f['local_user1'].refresh_from_db()
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(File.objects.filter(id=test_file.id).exists())
def test_user_info_patch_profile_picture_invalid(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
self.assertEqual(reply.status_code, 400)
def test_user_info_patch_profile_picture_id(self):
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
def test_user_info_patch_profile_picture_id_not_found(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
self.assertEqual(reply.status_code, 400)
def test_user_info_fail(self):
reply = self.anonymous_client.get('/auth/user/')
@ -308,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 403)
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
def setUp(self):
super().setUp()
self.prepare_users()
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
self.anonymous_client = Client(SERVER_NAME='testserver')
self.client = SignatureAuthClient()
def test_view_friend_profile(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
self.assertNotIn('email', reply.json())
def test_view_own_profile_via_handle(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
def test_view_profile_not_friend(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user2'])
self.assertEqual(reply.status_code, 403)
def test_view_profile_unknown_user(self):
target = '/auth/user/nosuchuser@example.com/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_view_profile_bad_handle(self):
target = '/auth/user/notahandle/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 400)
def test_view_profile_unauthenticated(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.anonymous_client.get(target)
self.assertEqual(reply.status_code, 403)
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
@ -319,26 +412,26 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
self.client = SignatureAuthClient()
def test_friend_local(self):
reply = self.client.get('/api/friends/', self.f['local_user1'])
reply = self.client.get('/api/v1/friends/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_friend_external(self):
reply = self.client.get('/api/friends/', self.f['ext_user1'])
reply = self.client.get('/api/v1/friends/', self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
def test_friend_fail(self):
reply = self.anonymous_client.get('/api/friends/')
reply = self.anonymous_client.get('/api/v1/friends/')
self.assertEqual(reply.status_code, 403)
def test_friend_fail2(self):
target = "/api/friends/"
target = "/api/v1/friends/"
signature = self.f['local_user1'].sign("http://testserver2" + target)
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(self.f['local_user1']) + ':' + signature}
reply = self.anonymous_client.get(target, **header)
self.assertEqual(reply.status_code, 403)
def test_friend_fail3(self):
target = "/api/friends/"
target = "/api/v1/friends/"
unknown_user = DummyExternalUser('extuser3', 'external.org', False)
signature = unknown_user.sign("http://testserver" + target)
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(unknown_user) + ':' + signature}
@ -346,7 +439,7 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 403)
def test_friend_fail4(self):
target = "/api/friends/"
target = "/api/v1/friends/"
signature = self.f['local_user1'].sign("http://testserver" + target)
header = {'HTTP_AUTHORIZATION': 'Auth ' + str(self.f['local_user1']) + ':' + signature}
reply = self.anonymous_client.get(target, **header)