diff --git a/.github/workflows/moodle-plugin-ci.yml b/.github/workflows/moodle-plugin-ci.yml index d6b8839..ea32dfe 100644 --- a/.github/workflows/moodle-plugin-ci.yml +++ b/.github/workflows/moodle-plugin-ci.yml @@ -35,6 +35,21 @@ jobs: database: [pgsql, mariadb] steps: + - name: Check out the plugin repository (but just to start the Wiremock docker container from the fixtures subdirectory) + uses: actions/checkout@v4 + with: + repository: lernlink/moodle-tool_directsso + # Ideally, we would checkout ${{ matrix.moodle-branch }} here. + # But when doing a plugin upgrade, this would let the build fail. + # Thus we checkout always the main branch and hope that the steps to start up the Bitnami container won't change too often. + ref: master + path: wiremock-container + + - name: Start Wiremock + uses: adambirds/docker-compose-action@v1.4.0 + with: + compose-file: ${{ github.workspace }}/wiremock-container/tests/fixtures/wiremock-docker-compose.yml + - name: Check out repository code uses: actions/checkout@v4 with: diff --git a/CHANGES.md b/CHANGES.md index 9c5f6e7..74f23fa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,10 @@ moodle-tool_directsso Changes ------- +### Unreleased + +* 2025-01-06 - Add automated tests with Wiremock. + ### v4.3-r2 * 2024-11-23 - Make codechecker happy again diff --git a/UPGRADE.md b/UPGRADE.md index 3cfb9f9..145524c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -21,195 +21,22 @@ Upstream changes Automated tests --------------- -* Due to the fact that the plugin deals with links from external applications to Moodle and with identity backend providers, there aren't any automated test (yet). +* The plugin has a good coverage with Behat tests which test all of the plugin's user stories. +* To run the automated tests, a running OAuth2 server is necessary. This is realized in the Github actions workflow with Wiremock. If you want to run the automated tests locally, you have to adapt the tests to a local OAuth2 server yourself. +If you do not have a running OAuth2 server at hand, you can try to spin up Wiremock which is used in Github actions with this docker-compose command: +``` +docker-compose -p wiremock -f admin/tool/directsso/tests/fixtures/wiremock-docker-compose.yml up +``` Manual tests ------------ -### Prerequisites: +* Even though there are automated tests, as the plugin deals with the communication to a backend system, manual tests should be carried out to see if the plugin's functionality really works with a real OAuth2 server. +* Additionally, if you look at the Behat feature file, you will see that there are some scenarios still commented out. If you have time, you should test them manually or write a Behat test for it. -* Install the plugin to Moodle -### Test OAuth backend +Visual checks +------------- -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Dashboard" -* Save the settings -* Pick the usable URL for the SSO entrypoint - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are logged into Moodle -* You are on the Moodle Dashboard - -### Test backend disabling - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Dashboard" -* Save the settings -* Pick the usable URL for the SSO entrypoint -* In the "Allowed authentication plugins" setting, disable "OAuth 2" -* Save the settings again - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are not logged into Moodle -* You are on the Moodle login page - -### Test wantspage disabling - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Dashboard" -* Save the settings -* Pick the usable URL for the SSO entrypoint -* In the "Allowed wantspage targets" setting, disable all options -* Save the settings again - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are not logged into Moodle -* You are on the Moodle login page - -### Test Dashboard wantspage target - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Dashboard" -* Save the settings -* Pick the usable URL for the SSO entrypoint - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are logged into Moodle -* You are on the Moodle Dashboard - -### Test Frontpage wantspage target - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Frontpage" -* Save the settings -* Pick the usable URL for the SSO entrypoint - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are logged into Moodle -* You are on the Moodle Frontpage - -### Test Course wantspage target - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend -* Create a course and enrol yourself into the course - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Course" -* Save the settings -* Pick the usable URL for the SSO entrypoint and replace the COURSEID placeholder with the ID of the course which you just created - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are logged into Moodle -* You are on the Moodle course - -### Test Course wantspage target without course ID - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Course" -* Save the settings -* Pick the usable URL for the SSO entrypoint and remove the courseid parameter from the URL - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are not logged into Moodle -* You see an error message which informs you that the required courseid parameter is missing - -### Test (non-existing) disabled wantspage fallback - -* Login as admin - -* Go to Site administration -> Server -> OAuth 2 services -* Add a working OAuth 2 backend - -* Go to Site administration -> Plugins -> Authentication -> Direct SSO Entrypoint -* In the "Allowed authentication plugins" setting, enable "OAuth 2" -* In the "Allowed wantspage targets" setting, enable "Frontpage" and "Dashboard" -* Save the settings -* Pick the usable URL for the SSO entrypoint to the frontpage -* In the "Allowed wantspage targets" setting, disable "Frontpage" but keep "Dashboard" enabled -* Save the settings again - -* Open a new private browser window -* Make sure that you are logged in with the SSO provider, but not into Moodle -* Go to the SSO entrypoint URL - -#### Expected result: - -* You are not logged into Moodle -* You are on the Moodle login page +* There aren't any additional visual checks in the Moodle GUI needed to upgrade this plugin. diff --git a/tests/behat/behat_tool_directsso.php b/tests/behat/behat_tool_directsso.php new file mode 100644 index 0000000..244172f --- /dev/null +++ b/tests/behat/behat_tool_directsso.php @@ -0,0 +1,253 @@ +. + +/** + * Admin tool "Direct SSO Entrypoint" - Behat step definitions + * + * @package tool_directsso + * @copyright 2024 Alexander Bias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); + +/** + * Step definitions. + * + * @package tool_directsso + * @copyright 2024 Alexander Bias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_tool_directsso extends behat_base { + /** + * The name of the OAuth2 issuer for Wiremock. + */ + private const OAUTH2_ISSUER_NAME = 'OAuth2 Wiremock'; + + /** + * The step makes sure that a OAuth2 provider is configured in a way that it can connect to a running Wiremock instance. + * + * @Given /^a OAuth2 provider for Wiremock is configured/ + * @return void + */ + public function a_oauth2_provider_for_wiremock_is_configured() { + global $DB; + + // Check if the OAuth2 provider already exists. + if ($DB->record_exists('oauth2_issuer', ['name' => self::OAUTH2_ISSUER_NAME])) { + // Return directly. + return; + } + + // Prepare the mdl_oauth2_issuer table entry. + $oauth2issuer = [ + 'timecreated' => 1732360328, + 'timemodified' => 1732399248, + 'usermodified' => 2, + 'name' => self::OAUTH2_ISSUER_NAME, + 'image' => '', + 'baseurl' => 'http://localhost:8080/oauth2', + 'clientid' => 'foo', + 'clientsecret' => 'bar', + 'loginscopes' => 'openid profile email', + 'loginscopesoffline' => 'openid profile email', + 'loginparams' => '', + 'loginparamsoffline' => '', + 'alloweddomains' => '', + 'scopessupported' => null, + 'enabled' => 1, + 'showonloginpage' => 2, + 'basicauth' => 1, + 'sortorder' => 0, + 'requireconfirmation' => 0, + 'servicetype' => '', + 'loginpagename' => self::OAUTH2_ISSUER_NAME, + ]; + + // Insert the record into the database and remember the ID of the newly added record. + $issuerid = $DB->insert_record('oauth2_issuer', $oauth2issuer); + + // Prepare the mdl_oauth2_endpoint table entries. + $oauth2endpoint1 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'name' => 'token_endpoint', + 'url' => 'http://localhost:8080/oauth/token', + 'issuerid' => $issuerid, + ]; + $oauth2endpoint2 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'name' => 'userinfo_endpoint', + 'url' => 'http://localhost:8080/oauth/userinfo', + 'issuerid' => $issuerid, + ]; + $oauth2endpoint3 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'name' => 'authorization_endpoint', + 'url' => 'http://localhost:8080/oauth/authorize', + 'issuerid' => $issuerid, + ]; + + // Prepare the mdl_oauth2_user_field_mapping table entries. + $oauth2userfieldmapping1 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'issuerid' => $issuerid, + 'externalfield' => 'email', + 'internalfield' => 'email', + ]; + $oauth2userfieldmapping2 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'issuerid' => $issuerid, + 'externalfield' => 'given_name', + 'internalfield' => 'firstname', + ]; + $oauth2userfieldmapping3 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'issuerid' => $issuerid, + 'externalfield' => 'family_name', + 'internalfield' => 'lastname', + ]; + $oauth2userfieldmapping4 = [ + 'timecreated' => 1732360420, + 'timemodified' => 1732399262, + 'usermodified' => 2, + 'issuerid' => $issuerid, + 'externalfield' => 'preferred_username', + 'internalfield' => 'username', + ]; + + // Insert the records into the database. + $DB->insert_record('oauth2_endpoint', $oauth2endpoint1); + $DB->insert_record('oauth2_endpoint', $oauth2endpoint2); + $DB->insert_record('oauth2_endpoint', $oauth2endpoint3); + $DB->insert_record('oauth2_user_field_mapping', $oauth2userfieldmapping1); + $DB->insert_record('oauth2_user_field_mapping', $oauth2userfieldmapping2); + $DB->insert_record('oauth2_user_field_mapping', $oauth2userfieldmapping3); + $DB->insert_record('oauth2_user_field_mapping', $oauth2userfieldmapping4); + } + + /** + * Open the login page. + * + * @Given /^I go to the login page$/ + */ + public function i_go_to_the_login_page() { + $this->execute('behat_general::i_visit', ['/login/index.php']); + } + + /** + * Opens the Direct SSO entrypoing URL with OAuth2 auth method and dashboard wantspage. + * + * @When I open the Direct SSO entrypoint URL with OAuth2 auth method and dashboard wantspage + * @return void + */ + public function i_open_the_direct_sso_entrypoint_url_oauth2_dashboard() { + $issuer = $this->get_oauth2_issuer_id(); + $url = new moodle_url('/admin/tool/directsso/login.php', ['auth' => 'oauth2', 'id' => $issuer, 'wantspage' => 'dashboard']); + $this->execute('behat_general::i_visit', [$url]); + } + + /** + * Opens the Direct SSO entrypoing URL with OAuth2 auth method and frontpage wantspage. + * + * @When I open the Direct SSO entrypoint URL with OAuth2 auth method and frontpage wantspage + * @return void + */ + public function i_open_the_direct_sso_entrypoint_url_oauth2_frontpage() { + $issuer = $this->get_oauth2_issuer_id(); + $url = new moodle_url('/admin/tool/directsso/login.php', ['auth' => 'oauth2', 'id' => $issuer, 'wantspage' => 'frontpage']); + $this->execute('behat_general::i_visit', [$url]); + } + + /** + * Opens the Direct SSO entrypoing URL with OAuth2 auth method and course wantspage. + * + * @When I open the Direct SSO entrypoint URL with OAuth2 auth method and course wantspage + * @return void + */ + public function i_open_the_direct_sso_entrypoint_url_oauth2_course() { + $issuer = $this->get_oauth2_issuer_id(); + $courseid = $this->get_wantspage_course_id(); + $url = new moodle_url('/admin/tool/directsso/login.php', ['auth' => 'oauth2', 'id' => $issuer, 'wantspage' => 'course', + 'courseid' => $courseid]); + $this->execute('behat_general::i_visit', [$url]); + } + + /** + * Opens the Direct SSO entrypoing URL with wrong auth method and dashboard wantspage. + * + * @When I open the Direct SSO entrypoint URL with wrong auth method and dashboard wantspage + * @return void + */ + public function i_open_the_direct_sso_entrypoint_url_wrongauth_dashboard() { + $issuer = $this->get_oauth2_issuer_id(); + $url = new moodle_url('/admin/tool/directsso/login.php', ['auth' => 'wrong', 'id' => $issuer, 'wantspage' => 'dashboard']); + $this->execute('behat_general::i_visit', [$url]); + } + + /** + * Opens the Direct SSO entrypoint URL with OAuth2 auth method and wrong course ID wantspage. + * + * @When I open the Direct SSO entrypoint URL with OAuth2 auth method and wrong course ID wantspage + * @return void + */ + public function i_open_the_direct_sso_entrypoint_url_oauth2_wrongcourse() { + $issuer = $this->get_oauth2_issuer_id(); + $courseid = $this->get_wantspage_course_id(); + $url = new moodle_url('/admin/tool/directsso/login.php', ['auth' => 'oauth2', 'id' => $issuer, 'wantspage' => 'course', + 'courseid' => $courseid + 100]); + $this->execute('behat_general::i_visit', [$url]); + } + + /** + * Get the ID of the OAuth2 issuer with the name "OAuth2 Wiremock". + * + * @return int + */ + private function get_oauth2_issuer_id() { + global $DB; + + // Basically, we would not expect more than one issuer with the name "OAuth2 Wiremock". + // However, we are using get_records() here to be on the safe side. + $issuers = $DB->get_records('oauth2_issuer', ['name' => self::OAUTH2_ISSUER_NAME], 'id ASC', 'id'); + + return array_pop($issuers)->id; + } + + /** + * Get the ID of the course with the shortname "C1". + * + * @return int + */ + private function get_wantspage_course_id() { + global $DB; + + return $DB->get_field('course', 'id', ['shortname' => 'C1']); + } +} diff --git a/tests/behat/tool_directsso.feature b/tests/behat/tool_directsso.feature new file mode 100644 index 0000000..5bd831f --- /dev/null +++ b/tests/behat/tool_directsso.feature @@ -0,0 +1,111 @@ +@tool @tool_directsso +Feature: Using the admin tool Direct SSO + In order to allow direct SSO logins + As admin + I need to be able to configure and provide direct SSO login URLs + + Background: + Given a OAuth2 provider for Wiremock is configured + And the following config values are set as admin: + | name | value | + | curlsecurityblockedhosts | | + | curlsecurityallowedport | | + | auth | email,oauth2 | + And the following "courses" exist: + | fullname | shortname | idnumber | + | Course 1 | C1 | C1 | + + Scenario: Login as a user using OAuth2 (not yet related to the Direct SSO plugin, just to be sure) + When I go to the login page + And ".login-identityproviders .login-identityprovider-btn" "css_element" should exist + And I follow "Wiremock" + Then I should see "Welcome, John" + + Scenario Outline: Login as user using Direct SSO with Dashboard wantspage + Given the following config values are set as admin: + | name | value | plugin | + | allowedauths | | tool_directsso | + | allowedwantspages | | tool_directsso | + And I open the Direct SSO entrypoint URL with OAuth2 auth method and dashboard wantspage + Then I see "Welcome, John" + And I see "You are logged in as" + And I see "Log in to Acceptance test site" + + Examples: + | auth | wantspage | shouldseeornot1 | shouldseeornot2 | + | oauth2 | dashboard | should | should not | + | oauth2 | dashboard,frontpage | should | should not | + | | dashboard | should not | should | + | oauth2 | | should not | should | + + Scenario Outline: Login as user using Direct SSO with frontpage wantspage + Given the following config values are set as admin: + | name | value | plugin | + | allowedauths | | tool_directsso | + | allowedwantspages | | tool_directsso | + And I open the Direct SSO entrypoint URL with OAuth2 auth method and frontpage wantspage + Then I see "Available courses" + And I see "You are logged in as" + And I see "Log in to Acceptance test site" + + Examples: + | auth | wantspage | shouldseeornot1 | shouldseeornot2 | + | oauth2 | frontpage | should | should not | + | oauth2 | dashboard,frontpage | should | should not | + | | frontpage | should not | should | + | oauth2 | | should not | should | + + Scenario Outline: Login as user using Direct SSO with course wantspage + Given the following config values are set as admin: + | name | value | plugin | + | allowedauths | | tool_directsso | + | allowedwantspages | | tool_directsso | + And I open the Direct SSO entrypoint URL with OAuth2 auth method and course wantspage + Then I see "Course 1" + And I see "Enrolment options" + And I see "You are logged in as" + And I see "Log in to Acceptance test site" + + Examples: + | auth | wantspage | shouldseeornot1 | shouldseeornot2 | + | oauth2 | course | should | should not | + | oauth2 | dashboard,course | should | should not | + | | course | should not | should | + | oauth2 | | should not | should | + + Scenario Outline: Login as user using Direct SSO with Dashboard wantspage, but wrong auth method (Countercheck) + Given the following config values are set as admin: + | name | value | plugin | + | allowedauths | | tool_directsso | + | allowedwantspages | | tool_directsso | + And I open the Direct SSO entrypoint URL with wrong auth method and dashboard wantspage + Then I see "Welcome, John" + And I see "You are logged in as" + And I see "Log in to Acceptance test site" + + Examples: + | auth | wantspage | shouldseeornot1 | shouldseeornot2 | + | oauth2 | dashboard | should not | should | + + Scenario Outline: Login as user using Direct SSO with course wantspage, but wrong course ID (Countercheck) + Given the following config values are set as admin: + | name | value | plugin | + | allowedauths | | tool_directsso | + | allowedwantspages | | tool_directsso | + And I open the Direct SSO entrypoint URL with OAuth2 auth method and wrong course ID wantspage + Then I see "Course 1" + And I see "Enrolment options" + And I see "You are logged in as" + And I see "Log in to Acceptance test site" + + Examples: + | auth | wantspage | shouldseeornot1 | shouldseeornot2 | + | oauth2 | course | should not | should | + + # Unfortunately, this can't be tested with Behat as Moodle core would throw a + # 'A required parameter (courseid) was missing' exception when calling the URL without a course ID. + # Scenario Outline: Login as user using Direct SSO with course wantspage, but without course ID (Countercheck) + + # Unfortunately, this can't be tested with Behat as Moodle core would throw a + # 'Can't find data record in database table oauth2_issuer' exception when using a wrong issuer ID. + # Scenario Outline: Login as user using Direct SSO with Dashboard wantspage, but wrong issuer ID (Countercheck) diff --git a/tests/fixtures/wiremock-docker-compose.yml b/tests/fixtures/wiremock-docker-compose.yml new file mode 100644 index 0000000..4d9b930 --- /dev/null +++ b/tests/fixtures/wiremock-docker-compose.yml @@ -0,0 +1,15 @@ +services: + wiremock: + image: wiremock/wiremock:3.9.2 + ports: + - "8080:8080" + volumes: + - ./wiremock-mappings:/home/wiremock/mappings + - ./wiremock-files:/home/wiremock/__files + entrypoint: ["/docker-entrypoint.sh", "--global-response-templating", "--verbose"] + +volumes: + wiremock-mappings: + driver: local + wiremock-files: + driver: local diff --git a/tests/fixtures/wiremock-files/.gitkeep b/tests/fixtures/wiremock-files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/wiremock-mappings/authorize.json b/tests/fixtures/wiremock-mappings/authorize.json new file mode 100644 index 0000000..4022b24 --- /dev/null +++ b/tests/fixtures/wiremock-mappings/authorize.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/oauth/authorize" + }, + "response": { + "status": 302, + "headers": { + "Location": "{{request.query.redirect_uri}}?state={{urlEncode request.query.state}}&code=foo" + }, + "transformers": ["response-template"] + } + } \ No newline at end of file diff --git a/tests/fixtures/wiremock-mappings/token.json b/tests/fixtures/wiremock-mappings/token.json new file mode 100644 index 0000000..8cd5350 --- /dev/null +++ b/tests/fixtures/wiremock-mappings/token.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "POST", + "urlPath": "/oauth/token" + }, + "response": { + "status": 200, + "jsonBody": { + "access_token": "mock_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock_refresh_token" + } + } + } \ No newline at end of file diff --git a/tests/fixtures/wiremock-mappings/userinfo.json b/tests/fixtures/wiremock-mappings/userinfo.json new file mode 100644 index 0000000..c6f9be3 --- /dev/null +++ b/tests/fixtures/wiremock-mappings/userinfo.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "urlPath": "/oauth/userinfo" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "sub": "1234567890", + "name": "John Doe", + "given_name": "John", + "family_name": "Doe", + "preferred_username": "johndoe", + "email": "johndoe@example.com" + } + } + } \ No newline at end of file