From 485c4001b19d1dcb3f831ff5484fb696f9832aba Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Thu, 12 May 2022 17:27:06 +0200
Subject: [PATCH] manager: add OpenID service handling (#20696)

---
 src/authentic2/manager/service_views.py       | 31 ++++++-
 .../static/authentic2/manager/css/style.scss  | 23 +++++
 .../templates/authentic2/manager/service.html | 16 +++-
 .../authentic2/manager/services.html          |  9 ++
 src/authentic2/manager/urls.py                | 37 ++++----
 src/authentic2/manager/utils.py               | 17 ++++
 src/authentic2/models.py                      |  5 ++
 src/authentic2_idp_oidc/manager/__init__.py   |  0
 src/authentic2_idp_oidc/manager/forms.py      | 64 +++++++++++++
 src/authentic2_idp_oidc/manager/urls.py       | 45 ++++++++++
 src/authentic2_idp_oidc/manager/views.py      | 87 ++++++++++++++++++
 src/authentic2_idp_oidc/models.py             | 28 ++++++
 .../manager/object_detail.html                | 31 +++++++
 tests/test_manager.py                         | 89 +++++++++++++++++++
 14 files changed, 455 insertions(+), 27 deletions(-)
 create mode 100644 src/authentic2_idp_oidc/manager/__init__.py
 create mode 100644 src/authentic2_idp_oidc/manager/forms.py
 create mode 100644 src/authentic2_idp_oidc/manager/urls.py
 create mode 100644 src/authentic2_idp_oidc/manager/views.py
 create mode 100644 src/authentic2_idp_oidc/templates/authentic2_idp_oidc/manager/object_detail.html

diff --git a/src/authentic2/manager/service_views.py b/src/authentic2/manager/service_views.py
index 372fc63e3..59e6f7622 100644
--- a/src/authentic2/manager/service_views.py
+++ b/src/authentic2/manager/service_views.py
@@ -39,7 +39,16 @@ class ServicesView(views.HideOUColumnMixin, views.BaseTableView):
 listing = ServicesView.as_view()
 
 
+class ServiceMixin:
+    def get_object(self, queryset=None):
+        service = super().get_object(queryset)
+        if hasattr(service, 'oidcclient'):
+            return service.oidcclient
+        return service
+
+
 class ServiceView(
+    ServiceMixin,
     views.SimpleSubTableView,
     role_views.RoleViewMixin,
     views.MediaMixin,
@@ -86,13 +95,14 @@ class ServiceView(
         kwargs['form'] = self.get_form()
         ctx = super().get_context_data(**kwargs)
         ctx['roles_table'] = tables.RoleTable(self.object.roles.all())
+        ctx.update(self.object.get_manager_context_data())
         return ctx
 
 
-roles = ServiceView.as_view()
+service_detail = ServiceView.as_view()
 
 
-class ServiceEditView(views.BaseEditView):
+class ServiceEditView(ServiceMixin, views.BaseEditView):
     model = Service
     pk_url_kwarg = 'service_pk'
     template_name = 'authentic2/manager/form.html'
@@ -101,5 +111,20 @@ class ServiceEditView(views.BaseEditView):
     fields = ['name', 'slug', 'ou', 'unauthorized_url']
     success_url = '..'
 
+    def get_form_class(self):
+        if self.object.manager_form_class:
+            return self.object.manager_form_class
+        return super().get_form_class()
+
+
+edit_service = ServiceEditView.as_view()
+
+
+class ServiceDeleteView(views.BaseDeleteView):
+    model = Service
+    pk_url_kwarg = 'service_pk'
+    permissions = ['authentic2.delete_service']
+    title = _('Delete OpenID Service')
+
 
-edit = ServiceEditView.as_view()
+delete_service = ServiceDeleteView.as_view()
diff --git a/src/authentic2/manager/static/authentic2/manager/css/style.scss b/src/authentic2/manager/static/authentic2/manager/css/style.scss
index c1818ab85..e18e7b52c 100644
--- a/src/authentic2/manager/static/authentic2/manager/css/style.scss
+++ b/src/authentic2/manager/static/authentic2/manager/css/style.scss
@@ -287,3 +287,26 @@ div.a2-manager-ldap pre {
 	max-width: 100%;
 	overflow-y: auto;
 }
+
+table.claims-table td.actions {
+	position: relative;
+	width: 80px;
+	a {
+		display: inline-block;
+		border: none;
+		overflow: hidden;
+		width: 30px;
+		height: 30px;
+		line-height: 30px;
+		&::before {
+			font-family: FontAwesome;
+			padding-right: 3em;
+		}
+		&.delete::before {
+			content: "\f057"; /* remove-sign */
+		}
+		&.edit::before {
+			content: "\f044"; /* edit-sign */
+		}
+	}
+}
diff --git a/src/authentic2/manager/templates/authentic2/manager/service.html b/src/authentic2/manager/templates/authentic2/manager/service.html
index 7fb3a3dfc..a1d5caaf0 100644
--- a/src/authentic2/manager/templates/authentic2/manager/service.html
+++ b/src/authentic2/manager/templates/authentic2/manager/service.html
@@ -1,16 +1,23 @@
-{% extends "authentic2/manager/services.html" %}
+{% extends "authentic2/manager/base.html" %}
 {% load i18n static django_tables2 %}
 
 {% block page-title %}{% firstof manager_site_title site_title "Authentic2" %} - {{ object }}{% endblock %}
 
 {% block breadcrumb %}
-  {{ block.super }}
+{{ block.super }}
+  <a href="{% url 'a2-manager-services' %}">{% trans 'Services' %}</a>
   <a href="{% url 'a2-manager-service' service_pk=view.kwargs.service_pk %}">{{ view.service.name }}</a>
 {% endblock %}
 
+{% block buttons %}
+{% endblock %}
+
 {% block appbar %}
   {{ block.super }}
   <span class="actions">
+  {% if view.can_delete %}
+  <a rel="popup" href="{% url "a2-manager-service-delete" service_pk=view.kwargs.service_pk %}">{% trans "Delete" %}</a>
+  {% endif %}
   {% if view.can_change %}
   <a rel="popup" href="{% url "a2-manager-service-edit" service_pk=view.kwargs.service_pk %}">{% trans "Edit" %}</a>
   {% endif %}
@@ -34,6 +41,11 @@
 {% endblock %}
 
 {% block main %}
+
+{% if extra_details_template %}
+  {% include extra_details_template %}
+{% endif %}
+
 <div class="section">
   <h3>{% trans "Roles of users allowed on this service" %}</h3>
   <div id="authorized-roles">
diff --git a/src/authentic2/manager/templates/authentic2/manager/services.html b/src/authentic2/manager/templates/authentic2/manager/services.html
index a28d518dd..cd98112bd 100644
--- a/src/authentic2/manager/templates/authentic2/manager/services.html
+++ b/src/authentic2/manager/templates/authentic2/manager/services.html
@@ -8,6 +8,15 @@
   <a href="{% url 'a2-manager-services' %}">{% trans 'Services' %}</a>
 {% endblock %}
 
+{% block appbar %}
+  {{ block.super }}
+  <span class="actions">
+    {% if view.can_add %}
+    <a href="{% url "a2-manager-add-oidc-service" %}">{% trans "Add OIDC service" %}</a>
+    {% endif %}
+  </span>
+{% endblock %}
+
 {% block sidebar %}
   <aside id="sidebar">
     {% include "authentic2/manager/search_form.html" %}
diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py
index 9ec4bf1ff..af6ac2911 100644
--- a/src/authentic2/manager/urls.py
+++ b/src/authentic2/manager/urls.py
@@ -14,33 +14,17 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from functools import wraps
-
 from django.conf.urls import url
-from django.utils.functional import lazy
 from django.views.i18n import JavaScriptCatalog
 
 from authentic2.apps.authenticators.manager_urls import urlpatterns as authenticator_urlpatterns
-from authentic2.utils import misc as utils_misc
+from authentic2_idp_oidc.manager.urls import urlpatterns as oidc_manager_urlpatterns
 
 from ..decorators import required
-from . import journal_views, ou_views, role_views, service_views, user_views, views
-
-
-def manager_login_required(func):
-    @wraps(func)
-    def _wrapped_view(request, *args, **kwargs):
-        if request.user.is_authenticated:
-            return func(request, *args, **kwargs)
-        return utils_misc.login_require(
-            request, login_url=lazy(utils_misc.get_manager_login_url, str)(), login_hint=['backoffice']
-        )
-
-    return _wrapped_view
-
+from . import journal_views, ou_views, role_views, service_views, user_views, utils, views
 
 urlpatterns = required(
-    manager_login_required,
+    utils.manager_login_required,
     [
         # homepage
         url(r'^$', views.homepage, name='a2-manager-homepage'),
@@ -182,9 +166,17 @@ urlpatterns = required(
         url(r'^organizational-units/import/$', ou_views.ous_import, name='a2-manager-ous-import'),
         # Services
         url(r'^services/$', service_views.listing, name='a2-manager-services'),
-        url(r'^services/(?P<service_pk>\d+)/$', service_views.roles, name='a2-manager-service'),
-        url(r'^services/(?P<service_pk>\d+)/edit/$', service_views.edit, name='a2-manager-service-edit'),
-        # Journal
+        url(r'^services/(?P<service_pk>\d+)/$', service_views.service_detail, name='a2-manager-service'),
+        url(
+            r'^services/(?P<service_pk>\d+)/edit/$',
+            service_views.edit_service,
+            name='a2-manager-service-edit',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/delete/$',
+            service_views.delete_service,
+            name='a2-manager-service-delete',
+        ),  # Journal
         url(r'^journal/$', journal_views.journal, name='a2-manager-journal'),
         url(
             r'^journal/event-types/$',
@@ -202,6 +194,7 @@ urlpatterns = required(
 )
 
 urlpatterns += authenticator_urlpatterns
+urlpatterns += oidc_manager_urlpatterns
 
 urlpatterns += [
     url(
diff --git a/src/authentic2/manager/utils.py b/src/authentic2/manager/utils.py
index cb2fc061f..ff6fd8ebc 100644
--- a/src/authentic2/manager/utils.py
+++ b/src/authentic2/manager/utils.py
@@ -14,7 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from functools import wraps
+
+from django.utils.functional import lazy
+
 from authentic2.a2_rbac.models import OrganizationalUnit
+from authentic2.utils import misc as utils_misc
 from authentic2.utils.cache import GlobalCache
 
 
@@ -44,3 +49,15 @@ def get_ou_count():
 @GlobalCache(timeout=10)
 def has_show_username():
     return not OrganizationalUnit.objects.filter(show_username=False).exists()
+
+
+def manager_login_required(func):
+    @wraps(func)
+    def _wrapped_view(request, *args, **kwargs):
+        if request.user.is_authenticated:
+            return func(request, *args, **kwargs)
+        return utils_misc.login_require(
+            request, login_url=lazy(utils_misc.get_manager_login_url, str)(), login_hint=['backoffice']
+        )
+
+    return _wrapped_view
diff --git a/src/authentic2/models.py b/src/authentic2/models.py
index 41487c26f..8cee4c10f 100644
--- a/src/authentic2/models.py
+++ b/src/authentic2/models.py
@@ -416,6 +416,8 @@ class Service(models.Model):
 
     objects = managers.ServiceManager()
 
+    manager_form_class = None
+
     def clean(self):
         errors = {}
 
@@ -499,6 +501,9 @@ class Service(models.Model):
 
         return super().delete(*args, **kwargs)
 
+    def get_manager_context_data(self):
+        return {}
+
 
 Service._meta.natural_key = [['slug', 'ou']]
 
diff --git a/src/authentic2_idp_oidc/manager/__init__.py b/src/authentic2_idp_oidc/manager/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/authentic2_idp_oidc/manager/forms.py b/src/authentic2_idp_oidc/manager/forms.py
new file mode 100644
index 000000000..5d457acc1
--- /dev/null
+++ b/src/authentic2_idp_oidc/manager/forms.py
@@ -0,0 +1,64 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2022 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django import forms
+
+from authentic2.attributes_ng.engine import get_service_attributes
+from authentic2.forms.mixins import SlugMixin
+from authentic2.forms.widgets import DatalistTextInput
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
+
+
+class OIDCClientForm(SlugMixin, forms.ModelForm):
+    class Meta:
+        model = OIDCClient
+        fields = [
+            'name',
+            'redirect_uris',
+            'post_logout_redirect_uris',
+            'sector_identifier_uri',
+            'frontchannel_logout_uri',
+            'ou',
+            'identifier_policy',
+            'idtoken_algo',
+            'unauthorized_url',
+            'authorization_mode',
+            'authorization_flow',
+            'home_url',
+            'colour',
+            'logo',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['colour'].widget = forms.TextInput(attrs={'type': 'color'})
+
+
+class OIDCClaimForm(forms.ModelForm):
+    class Meta:
+        model = OIDCClaim
+        fields = ('name', 'value', 'scopes')
+        widgets = {
+            'value': DatalistTextInput,
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        data = dict(get_service_attributes(getattr(self.instance, 'client', None))).keys()
+        widget = self.fields['value'].widget
+        widget.data = data
+        widget.name = 'list__oidcclaim-inline'
+        widget.attrs.update({'list': 'list__oidcclaim-inline'})
diff --git a/src/authentic2_idp_oidc/manager/urls.py b/src/authentic2_idp_oidc/manager/urls.py
new file mode 100644
index 000000000..117ebb334
--- /dev/null
+++ b/src/authentic2_idp_oidc/manager/urls.py
@@ -0,0 +1,45 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2022p Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from django.conf.urls import url
+
+from authentic2.decorators import required
+from authentic2.manager.utils import manager_login_required
+
+from . import views
+
+urlpatterns = required(
+    manager_login_required,
+    [
+        url(r'^services/add-oidc/$', views.add_oidc_service, name='a2-manager-add-oidc-service'),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/add/$',
+            views.oidc_claim_add,
+            name='a2-manager-oidc-claim-add',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/(?P<claim_pk>\d+)/edit/$',
+            views.oidc_claim_edit,
+            name='a2-manager-oidc-claim-edit',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/(?P<claim_pk>\d+)/delete/$',
+            views.oidc_claim_delete,
+            name='a2-manager-oidc-claim-delete',
+        ),
+    ],
+)
diff --git a/src/authentic2_idp_oidc/manager/views.py b/src/authentic2_idp_oidc/manager/views.py
new file mode 100644
index 000000000..a491b211e
--- /dev/null
+++ b/src/authentic2_idp_oidc/manager/views.py
@@ -0,0 +1,87 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2022 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.urls import reverse
+from django.utils.translation import ugettext_lazy as _
+
+from authentic2.manager import views
+from authentic2_idp_oidc import app_settings
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
+
+from . import forms
+
+
+class OIDCServiceAddView(views.ActionMixin, views.BaseAddView):
+    form_class = forms.OIDCClientForm
+    model = OIDCClient
+    title = _('Add OIDC service')
+    permissions = ['authentic2.add_service']
+    action = _('Add')
+
+    def get_success_url(self):
+        # add default claims mappings after creating service
+        for mapping in app_settings.DEFAULT_MAPPINGS:
+            OIDCClaim.objects.get_or_create(client=self.object, **mapping)
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.pk})
+
+
+add_oidc_service = OIDCServiceAddView.as_view()
+
+
+class OIDCClaimAddView(views.ActionMixin, views.BaseAddView):
+    form_class = forms.OIDCClaimForm
+    model = OIDCClaim
+    title = _('Add OIDC Claim')
+    action = _('Add')
+
+    def form_valid(self, form):
+        obj = form.save(commit=False)
+        obj.client = OIDCClient.objects.get(pk=self.kwargs['service_pk'])
+        obj.save()
+        return super().form_valid(form)
+
+    def get_success_url(self):
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.client.pk})
+
+
+oidc_claim_add = OIDCClaimAddView.as_view()
+
+
+class BaseClaimView:
+    model = OIDCClaim
+    pk_url_kwarg = 'claim_pk'
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        return qs.filter(client__pk=self.kwargs['service_pk'])
+
+    def get_success_url(self):
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.client.pk})
+
+
+class OIDCClaimEditView(BaseClaimView, views.BaseEditView):
+    title = _('Edit OpenID Claim')
+    form_class = forms.OIDCClaimForm
+
+
+oidc_claim_edit = OIDCClaimEditView.as_view()
+
+
+class OIDCClaimDeleteView(BaseClaimView, views.BaseDeleteView):
+    title = _('Delete OpenID Claim')
+
+
+oidc_claim_delete = OIDCClaimDeleteView.as_view()
diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py
index 55a870e84..bf89e1faa 100644
--- a/src/authentic2_idp_oidc/models.py
+++ b/src/authentic2_idp_oidc/models.py
@@ -238,6 +238,34 @@ class OIDCClient(Service):
             self.get_identifier_policy_display(),
         )
 
+    @property
+    def manager_form_class(self):
+        from .manager.forms import OIDCClientForm
+
+        return OIDCClientForm
+
+    def get_manager_fields(self):
+        # add client id and secret
+        display_fields = ['client_id', 'client_secret'] + self.manager_form_class._meta.fields
+        # but remove name because it's already displayed
+        if 'name' in display_fields:
+            display_fields.remove('name')
+        for field in display_fields:
+            field_value = getattr(self, field)
+            if not field_value:
+                continue
+            if hasattr(self, 'get_%s_display' % field):
+                field_value = getattr(self, 'get_%s_display' % field)()
+            yield self._meta.get_field(field).verbose_name, field_value
+
+    def get_manager_context_data(self):
+        ctx = {
+            'claims': self.oidcclaim_set.all(),
+            'object_fields': self.get_manager_fields(),
+            'extra_details_template': 'authentic2_idp_oidc/manager/object_detail.html',
+        }
+        return ctx
+
 
 class OIDCAuthorization(models.Model):
     client_ct = models.ForeignKey(
diff --git a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/manager/object_detail.html b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/manager/object_detail.html
new file mode 100644
index 000000000..d25a2bf53
--- /dev/null
+++ b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/manager/object_detail.html
@@ -0,0 +1,31 @@
+{% load i18n %}
+{% for field, value in object_fields %}
+  <p>{{ field|capfirst }}{% trans ":" %}  {% if value == True %}{% trans "yes" %}
+  {% elif value == False %}{% trans "no" %}
+  {% else %}{{value}}
+  {% endif %}</p>
+{% endfor %}
+
+  <div class="section">
+  <h3>{% trans "OIDC Claims" %}<a href="{% url "a2-manager-oidc-claim-add" service_pk=object.pk %}" class="button" rel="popup">{% trans "Add claim" %}</a></h3>
+  {% if claims %}
+  <table class="main claims-table" id="oidc-claims">
+    <thead>
+      <tr><th>{% trans "Name" %}</th><th>{% trans "Value" %}</th><th>{% trans "Scopes" %}</th><th></th></tr>
+    </thead>
+    <tbody>
+    {% for claim in claims %}
+    <tr>
+      <td>{{ claim.name }}</td>
+      <td>{{ claim.value }}</td>
+      <td>{{ claim.scopes }}</td>
+      <td class="actions">
+        <a class="edit" href="{% url "a2-manager-oidc-claim-edit" service_pk=object.pk claim_pk=claim.pk %}" rel="popup" title="{% trans "Edit" %}">{% trans "Edit" %}</a>
+        <a class="delete" href="{% url "a2-manager-oidc-claim-delete" service_pk=object.pk claim_pk=claim.pk %}" rel="popup" title="{% trans "Delete" %}">{% trans "Delete" %}</a>
+      </td>
+    </tr>
+    {% endfor %}
+    </tbody>
+  </table>
+  {% endif %}
+</div>
diff --git a/tests/test_manager.py b/tests/test_manager.py
index e17d9a955..c57957cb4 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -34,6 +34,8 @@ from authentic2.a2_rbac.utils import get_default_ou
 from authentic2.apps.journal.models import Event
 from authentic2.models import Service
 from authentic2.validators import EmailValidator
+from authentic2_idp_oidc import app_settings as oidc_app_settings
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
 from django_rbac.models import VIEW_OP
 from django_rbac.utils import get_operation
 
@@ -1220,3 +1222,90 @@ def test_manager_service_search(app, admin, ou1):
     resp = resp.form.submit()
     assert 'Test Service' in resp.text
     assert 'Example Service' not in resp.text
+
+
+def test_manager_add_oidc_service(app, superuser):
+    resp = login(app, superuser, 'a2-manager-services')
+    assert 'Add OIDC service' in resp.text
+    assert OIDCClient.objects.count() == 0
+    assert OIDCClaim.objects.count() == 0
+
+    resp = resp.click('Add OIDC service')
+    form = resp.form
+    form['name'] = 'Test'
+    form['redirect_uris'] = 'http://example.com'
+    resp = form.submit()
+
+    assert OIDCClient.objects.count() == 1
+    assert OIDCClaim.objects.count() == len(oidc_app_settings.DEFAULT_MAPPINGS)
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': OIDCClient.objects.get().pk})
+
+    resp = resp.follow()
+    assert "<h3>OIDC Claims" in resp.text
+    assert "Add claim" in resp.text
+    assert resp.pyquery.remove_namespaces()('#oidc-claims tbody tr').length == len(
+        oidc_app_settings.DEFAULT_MAPPINGS
+    )
+
+
+def test_manager_edit_oidc_service(app, superuser):
+    OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, 'a2-manager-services')
+    resp = resp.click('Test')
+    resp = resp.click('Edit')
+    form = resp.form
+    form['name'] = 'New Test'
+    form['colour'] = '#ff00ff'
+    resp = form.submit()
+    assert resp.location == '..'
+    resp = resp.follow()
+    assert "New Test" in resp.text
+    assert "#ff00ff" in resp.text
+
+
+def test_manager_delete_oidc_service(app, superuser):
+    OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, 'a2-manager-services')
+    resp = resp.click('Test')
+    resp = resp.click('Delete')
+    resp = resp.form.submit().follow()
+    assert OIDCClient.objects.count() == 0
+
+
+def test_manager_add_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    resp = resp.click('Add claim')
+    form = resp.form
+    form['name'] = 'claim'
+    form['value'] = 'value'
+    form['scopes'] = 'profile'
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
+
+
+def test_manager_edit_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    OIDCClaim.objects.create(client=client, name='claim', value='value', scopes='profile')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    assert "claim" in resp.text
+    resp = resp.click('Edit', index=1)
+    form = resp.form
+    form['value'] = 'new value'
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert not OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
+    assert OIDCClaim.objects.filter(client=client, name='claim', value='new value', scopes='profile').exists()
+
+
+def test_manager_delete_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    OIDCClaim.objects.create(client=client, name='claim', value='value', scopes='profile')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    assert "claim" in resp.text
+    resp = resp.click('Delete', index=1)
+    form = resp.form
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert not OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
-- 
2.36.1

