diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue index 17a608603b..bb7245c21d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue @@ -4,6 +4,8 @@
+ +

{{ $tr('inviteSubheading') }}

@@ -64,6 +66,7 @@ + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0152_channel_support_token.py b/contentcuration/contentcuration/migrations/0152_channel_support_token.py new file mode 100644 index 0000000000..e8141600a8 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0152_channel_support_token.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.24 on 2025-02-23 09:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0151_alter_assessmentitem_type'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='support_token', + field=models.OneToOneField(blank=True, help_text='Token for support access', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='support_channels', to='contentcuration.secrettoken', verbose_name='support token'), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 8999c30f53..31f9973afb 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -801,6 +801,15 @@ class Channel(models.Model): verbose_name="secret tokens", blank=True, ) + support_token = models.OneToOneField( + SecretToken, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='support_channels', + verbose_name="support token", + help_text="Token for support access", + ) source_url = models.CharField(max_length=200, blank=True, null=True) demo_server_url = models.CharField(max_length=200, blank=True, null=True) diff --git a/contentcuration/contentcuration/tests/views/test_admin.py b/contentcuration/contentcuration/tests/views/test_admin.py new file mode 100644 index 0000000000..eb2ad7a8f9 --- /dev/null +++ b/contentcuration/contentcuration/tests/views/test_admin.py @@ -0,0 +1,87 @@ +import uuid +from django.urls import reverse +from rest_framework import status +from contentcuration.models import Channel, SecretToken +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioTestCase + +class SupportTokenRedirectTestCase(StudioTestCase): + + @classmethod + def setUpClass(cls): + super(SupportTokenRedirectTestCase, cls).setUpClass() + + @property + def channel_metadata(self): + return { + "name": "Test Channel", + "id": uuid.uuid4().hex, + "description": "A test channel for support token creation.", + } + + def setUp(self): + """ + Set up test data before running each test. + """ + self.setUpBase() + + self.user.is_admin = True + self.user.save() + + self.channel = Channel.objects.create(actor_id=self.user.id, **self.channel_metadata) + + self.valid_token_str = "bepud-dibub-dizok" + self.support_token = SecretToken.objects.create(token=self.valid_token_str) + + self.channel.secret_tokens.add(self.support_token) + self.channel.support_token = self.support_token + self.channel.save() + + self.client = self.admin_client() + + def test_valid_token_redirects_to_channel(self): + """ + Test that a valid token redirects to the correct channel page. + """ + url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, f"channels/{self.channel.id}") + + def test_invalid_token_format(self): + """ + Test that an invalid token format returns 400 Bad Request. + """ + url = reverse("support_token_redirect", kwargs={"token": "invalid_token_123"}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Invalid token format", response.json()["Error"]) + + def test_token_not_found(self): + """ + Test that a non-existent token returns 404 Not Found. + """ + url = reverse("support_token_redirect", kwargs={"token": "bepud-befud-bidup"}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("Invalid token", response.json()["Error"]) + + + def test_unauthorized_user_forbidden(self): + """ + Test that a non-admin user gets 403 Forbidden. + """ + self.client.logout() + non_admin_user = testdata.user() + non_admin_user.is_admin = False + non_admin_user.save() + self.client.force_login(non_admin_user) + + url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 17549ab128..084b75b5bb 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -12,7 +12,7 @@ from contentcuration import models from contentcuration import models as cc from contentcuration.constants import channel_history -from contentcuration.models import ContentNode +from contentcuration.models import ContentNode, SecretToken from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event @@ -706,3 +706,85 @@ def _perform_action(self, url_path, channel_id): self.client.force_authenticate(user=user) response = self.client.get(reverse(url_path, kwargs={"pk": channel_id}), format="json") return response + +class SupportTokenTestCase(StudioAPITestCase): + + @property + def channel_metadata(self): + return { + "name": "Test Channel", + "id": uuid.uuid4().hex, + "description": "A test channel for support token creation.", + } + + def setUp(self): + super(SupportTokenTestCase, self).setUp() + self.user = testdata.user() + self.channel = models.Channel.objects.create(actor_id=self.user.id, **self.channel_metadata) + self.channel.editors.add(self.user) + self.client.force_authenticate(user=self.user) + self.channel.save() + + def test_create_support_token(self): + """ + Ensure a support token is created successfully for a channel. + """ + url = reverse("channel-support-token", kwargs={"pk": self.channel.id}) + response = self.client.post(url, format="json") + self.assertEqual(response.status_code, 201, response.content) + + self.channel.refresh_from_db() + self.assertIsNotNone(self.channel.support_token) + + # Ensure token exists in SecretToken model + token_exists = models.SecretToken.objects.filter(token=self.channel.support_token.token).exists() + self.assertTrue(token_exists, "Support token was not stored in the SecretToken table.") + + def test_cannot_create_duplicate_support_token(self): + """ + Ensure creating a support token fails if one already exists. + """ + # First request creates the token + url = reverse("channel-support-token", kwargs={"pk": self.channel.id}) + response = self.client.post(url, format="json") + self.assertEqual(response.status_code, 201, response.content) + + # Second request should fail with 409 CONFLICT + response = self.client.post(url, format="json") + self.assertEqual(response.status_code, 409, response.content) + + def test_get_support_token_existing(self): + """ + Ensure the API returns the support token when it exists. + """ + # Create a support token + # token_str = SecretToken.generate_new_token() + support_token = SecretToken.objects.create(token = "test-support-token") + self.channel.support_token = support_token + self.channel.save() + + url = reverse("channel-support-token", kwargs={"pk": self.channel.id}) + response = self.client.get(url, format="json") + print(response.content) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["support_token"], "test-support-token") + + def test_get_support_token_channel_not_found(self): + """ + Ensure the API returns 404 when the channel does not exist. + """ + url = reverse("channel-support-token", kwargs={"pk": "non-existent-id"}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, 404, response.content) + + def test_get_support_token_none(self): + """ + Ensure the API returns null when no support token exists. + """ + url = reverse("channel-support-token", kwargs={"pk": self.channel.id}) + response = self.client.get(url, format="json") + + self.assertEqual(response.status_code, 200, response.content) + self.assertIsNone(response.json()["support_token"]) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 8047ca0bf4..9cdd2a41ba 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -137,6 +137,7 @@ def get_redirect_url(self, *args, **kwargs): # Add admin endpoints urlpatterns += [ re_path(r'^api/send_custom_email/$', admin_views.send_custom_email, name='send_custom_email'), + re_path(r'^api/support_token_redirect/(?P[^/]+)/$', admin_views.support_token_redirect, name='support_token_redirect'), ] urlpatterns += [re_path(r'^jsreverse/$', django_js_reverse_views.urls_js, name='js_reverse')] diff --git a/contentcuration/contentcuration/views/admin.py b/contentcuration/contentcuration/views/admin.py index 7a55052cbb..53683c63fe 100644 --- a/contentcuration/contentcuration/views/admin.py +++ b/contentcuration/contentcuration/views/admin.py @@ -1,14 +1,18 @@ import json +import re from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import redirect from django.shortcuts import render +from django.shortcuts import get_object_or_404 from rest_framework.authentication import BasicAuthentication from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import api_view from rest_framework.decorators import authentication_classes +from rest_framework.decorators import permission_classes from rest_framework.response import Response from .json_dump import json_for_parse_from_data @@ -17,6 +21,9 @@ from contentcuration.tasks import sendcustomemails_task from contentcuration.utils.messages import get_messages from contentcuration.views.base import current_user_for_context +from contentcuration.models import SecretToken, Channel +from contentcuration.viewsets.user import IsAdminUser +from contentcuration.viewsets.user import IsAuthenticated @is_admin @@ -30,6 +37,30 @@ def send_custom_email(request): return Response({"success": True}) +@api_view(['GET']) +@permission_classes([IsAuthenticated, IsAdminUser]) +def support_token_redirect(request, token): + try: + # Inline regex for validating Proquint tokens + token_regex = re.compile( + r"^([bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz])" + r"(-[bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz][aeiou][bdfghjklmnprstvz])*$" + ) + + if not token_regex.fullmatch(token): + return Response({"Error": "Invalid token format"}, status=400) + + token_instance = SecretToken.objects.filter(token=token).first() + if not token_instance: + return Response({"Error": "Invalid token"}, status=404) + + channel = get_object_or_404(Channel, support_token__token=token) + + # Redirect to the channel edit page + return redirect(f"channels/{channel.id}") + + except Channel.DoesNotExist: + return Response({"Error": "Channel not found for token"}, status=404) @login_required @browser_is_supported diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 5cd9b84104..f7021665cb 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -32,6 +32,7 @@ from rest_framework.serializers import CharField from rest_framework.serializers import FloatField from rest_framework.serializers import IntegerField +from rest_framework.status import HTTP_409_CONFLICT from rest_framework.status import HTTP_201_CREATED from rest_framework.status import HTTP_204_NO_CONTENT from search.models import ChannelFullTextSearch @@ -777,7 +778,42 @@ def _get_channel_content_languages(self, channel_id, main_tree_id=None) -> List[ logging.error(str(e)) unique_lang_ids = [] return unique_lang_ids + + @action(detail=True, methods=["get", "post"], url_path="support_token", url_name="support-token") + def support_token(self, request, pk=None) -> Union[JsonResponse, HttpResponse, Response]: + """ + Handles both: + - GET: Retrieve the existing support token for this channel. + - POST: Create a new support token for this channel. + """ + if not self._channel_exists(pk): + return HttpResponseNotFound("No channel matching: {}".format(pk)) + + channel = self.get_edit_queryset().get(pk=pk) + + # Handle GET: Retrieve the support token + if request.method == "GET": + token_value = None + if channel.support_token: + token_value = channel.support_token.token + return JsonResponse({"support_token": token_value}) + + # Handle POST: Create a new support token + if request.method == "POST": + if channel.support_token is not None: + return Response( + {"error": "Support token already exists for this channel."}, + status=HTTP_409_CONFLICT + ) + + token_str = SecretToken.generate_new_token() + support_token = SecretToken.objects.create(token=token_str) + + channel.support_token = support_token + channel.save(update_fields=["support_token"]) + data = self.serialize_object(pk=channel.pk) + return Response(data, status=HTTP_201_CREATED) @method_decorator( cache_page(