Repository: privacyidea/privacyidea

Issue:
API endpoints without trailing slash
Some API endpoints do not have a trailing slash defined which leads to 404s (i.e. when viewing/adding a SMS gateway).
The common ground seems to be, that resources ("resource groups"), which contain other resources, have a trailing slash: https://softwareengineering.stackexchange.com/questions/186959/trailing-slash-in-restful-api
Flask supports this by redirecting in case an URI is missing the trailing slash: https://flask.palletsprojects.com/en/1.1.x/quickstart/#unique-urls-redirection-behavior
Currently this effects the event and smsgateway endpoints.


Discussion:

Resolving patch:
diff --git a/privacyidea/api/event.py b/privacyidea/api/event.py
--- a/privacyidea/api/event.py
+++ b/privacyidea/api/event.py
@@ -46,7 +46,7 @@
 eventhandling_blueprint = Blueprint('eventhandling_blueprint', __name__)
 
 
-@eventhandling_blueprint.route('', methods=['GET'])
+@eventhandling_blueprint.route('/', methods=['GET'])
 @eventhandling_blueprint.route('/<eventid>', methods=['GET'])
 @log_with(log)
 @prepolicy(check_base_action, request, ACTION.EVENTHANDLINGREAD)
@@ -66,9 +66,8 @@ def get_eventhandling(eventid=None):
         res = AVAILABLE_EVENTS
     elif eventid == "handlermodules":
         # TODO: We need to provide a dynamic list of event handlers
-        res = ["UserNotification", "Token", "Federation", "Script", "Counter"]
-        res.append("RequestMangler")
-        res.append("ResponseMangler")
+        res = ["UserNotification", "Token", "Federation", "Script", "Counter",
+               "RequestMangler", "ResponseMangler"]
     else:
         res = g.event_config.get_event(eventid)
     g.audit_object.log({"success": True})
diff --git a/privacyidea/api/smsgateway.py b/privacyidea/api/smsgateway.py
--- a/privacyidea/api/smsgateway.py
+++ b/privacyidea/api/smsgateway.py
@@ -48,7 +48,7 @@
 smsgateway_blueprint = Blueprint('smsgateway_blueprint', __name__)
 
 
-@smsgateway_blueprint.route('', methods=['GET'])
+@smsgateway_blueprint.route('/', methods=['GET'])
 @smsgateway_blueprint.route('/<gwid>', methods=['GET'])
 @log_with(log)
 @prepolicy(check_base_action, request, ACTION.SMSGATEWAYREAD)
@@ -65,7 +65,7 @@ def get_gateway(gwid=None):
     res = {}
     # TODO: if the gateway definitions contains a password normal users should
     #  not be allowed to read the configuration. Normal users should only be
-    # allowed to read the identifier of the definitions!
+    #  allowed to read the identifier of the definitions!
     if gwid == "providers":
         for classname in SMS_PROVIDERS:
             smsclass = get_sms_provider_class(classname.rsplit(".", 1)[0],
@@ -142,4 +142,3 @@ def delete_gateway_option(gwid=None, option=None):
                         "info": u"{0!s}/{1!s}".format(gwid, option)})
 
     return send_result(res)
-


Test patch:
diff --git a/tests/test_api_events.py b/tests/test_api_events.py
--- a/tests/test_api_events.py
+++ b/tests/test_api_events.py
@@ -10,7 +10,7 @@ class APIEventsTestCase(MyApiTestCase):
 
     def test_00_api_errors(self):
         # check for auth error
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET'):
             res = self.app.full_dispatch_request()
             self.assertEqual(res.status_code, 401, res)
@@ -43,20 +43,14 @@ def test_00_api_errors(self):
             self.assertEqual(res.status_code, 404, res)
             self.assertEqual(res.json['result']['error']['code'], 601, res.json)
         # And check if the /event request writes a valid (failed) audit entry
-        with self.app.test_request_context('/audit/',
-                                           method='GET',
-                                           data={'action': 'POST /event/enable/<eventid>'},
-                                           headers={'Authorization': self.at}):
-            res = self.app.full_dispatch_request()
-            self.assertTrue(res.status_code == 200, res)
-            auditentry = res.json.get("result").get("value").get("auditdata")[0]
-            self.assertEqual(auditentry['action'], 'POST /event/enable/<eventid>', auditentry)
-            self.assertEqual(auditentry['success'], 0, auditentry)
+        auditentry = self.find_most_recent_audit_entry(action='POST /event/enable/<eventid>')
+        self.assertEqual(auditentry['action'], 'POST /event/enable/<eventid>', auditentry)
+        self.assertEqual(auditentry['success'], 0, auditentry)
 
     def test_01_crud_events(self):
 
         # list empty events
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
 
@@ -67,14 +61,8 @@ def test_01_crud_events(self):
             self.assertIn('signature', res.json, res.json)
 
         # Check if the /event request writes a valid audit entry
-        with self.app.test_request_context('/audit/',
-                                           method='GET',
-                                           headers={'Authorization': self.at}):
-            res = self.app.full_dispatch_request()
-            self.assertTrue(res.status_code == 200, res)
-            auditentry = res.json.get("result").get("value").get("auditdata")[0]
-            self.assertEqual(auditentry['action'], 'GET /event', auditentry)
-            self.assertEqual(auditentry['success'], 1, auditentry)
+        auditentry = self.find_most_recent_audit_entry(action='GET /event/')
+        self.assertEqual(auditentry['success'], 1, auditentry)
 
         # create an event configuration
         param = {
@@ -95,7 +83,7 @@ def test_01_crud_events(self):
             self.assertEqual(result.get("value"), 1)
 
         # check the event
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -129,7 +117,7 @@ def test_01_crud_events(self):
             self.assertEqual(result.get("value"), 1)
 
         # check the event
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -169,7 +157,7 @@ def test_01_crud_events(self):
             self.assertEqual(result.get("value"), 1)
 
         # list empty events
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -201,7 +189,7 @@ def test_02_test_options(self):
             self.assertEqual(result.get("value"), 1)
 
         # list event with options
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -228,7 +216,7 @@ def test_02_test_options(self):
             self.assertEqual(result.get("value"), 1)
 
         # list empty events
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -305,7 +293,7 @@ def test_06_test_enable_disable(self):
             self.assertEqual(result.get("value"), 1)
 
         # list event with options
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -324,7 +312,7 @@ def test_06_test_enable_disable(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
 
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -343,7 +331,7 @@ def test_06_test_enable_disable(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
 
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -366,7 +354,7 @@ def test_06_test_enable_disable(self):
             self.assertEqual(result.get("value"), 1)
 
         # list empty events
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -451,7 +439,7 @@ def test_07_positions(self):
             self.assertEqual(result.get("value"), 1)
 
         # list empty events
-        with self.app.test_request_context('/event',
+        with self.app.test_request_context('/event/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -537,4 +525,4 @@ def test_08_create_token_for_user(self):
             result = res.json.get("result")
             detail = res.json.get("detail")
             self.assertEqual(result.get("value").get("count"), 1)
-            self.assertEqual(result.get("value").get("tokens")[0].get("tokentype"), u"email")
\ No newline at end of file
+            self.assertEqual(result.get("value").get("tokens")[0].get("tokentype"), u"email")
diff --git a/tests/test_api_smsgateway.py b/tests/test_api_smsgateway.py
--- a/tests/test_api_smsgateway.py
+++ b/tests/test_api_smsgateway.py
@@ -9,7 +9,7 @@ class APISmsGatewayTestCase(MyApiTestCase):
     def test_01_crud_smsgateway(self):
 
         # list empty sms gateway definitions
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
 
@@ -41,7 +41,7 @@ def test_01_crud_smsgateway(self):
             self.assertEqual(result.get("value"), 1)
 
         # check the gateway
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -90,7 +90,6 @@ def test_01_crud_smsgateway(self):
             sms_gw = result.get("value")[0]
             self.assertEqual(sms_gw.get("description"), "new description")
 
-
         # delete gateway
         with self.app.test_request_context('/smsgateway/myGW',
                                            method='DELETE',
@@ -104,7 +103,7 @@ def test_01_crud_smsgateway(self):
             self.assertEqual(result.get("value"), 1)
 
         # list empty gateways
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -145,7 +144,7 @@ def test_02_test_options(self):
             result = res.json.get("result")
 
         # check options
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={
                                                    'Authorization': self.at}):
@@ -168,7 +167,7 @@ def test_02_test_options(self):
             detail = res.json.get("detail")
 
         # check options
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):
@@ -242,7 +241,7 @@ def test_05_read_write_policies(self):
         # delete the read policy
         delete_policy("pol_read")
 
-        with self.app.test_request_context('/smsgateway',
+        with self.app.test_request_context('/smsgateway/',
                                            method='GET',
                                            headers={
                                                'Authorization': self.at}):