diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dad70e4..989083b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - develop pull_request: branches: - main + - develop jobs: call-tests: @@ -14,7 +16,3 @@ jobs: uses: phpbb-extensions/test-framework/.github/workflows/tests.yml@3.3.x with: EXTNAME: phpbb/consentmanager - RUN_MYSQL_JOBS: 0 - RUN_PGSQL_JOBS: 0 - RUN_MSSQL_JOBS: 0 - RUN_WINDOWS_JOBS: 0 diff --git a/README.md b/README.md index 99c1ff0..30ccc20 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > This extension is under development and will become available on [phpBB.com](https://phpbb.com) when it's ready +[![Build Status](https://github.com/phpbb-extensions/consentmanager/actions/workflows/tests.yml/badge.svg)](https://github.com/phpbb-extensions/consentmanager/actions) + Consent Manager is a GDPR-ready privacy/cookie consent management solution built for phpBB forums. It adds a consent banner, settings modal, and category-based controls, allowing visitors to accept all, reject all, or choose specific tracking types. A footer link lets users revisit and update their preferences at any time. diff --git a/tests/controller/acp_controller_test.php b/tests/controller/acp_controller_test.php new file mode 100644 index 0000000..edf7b0e --- /dev/null +++ b/tests/controller/acp_controller_test.php @@ -0,0 +1,289 @@ +language = new \phpbb\language\language($lang_loader); + $this->language->add_lang('common'); + $this->language->add_lang('acp_consentmanager', 'phpbb/consentmanager'); + + $this->user = new \phpbb\user($this->language, '\phpbb\datetime'); + $this->user->data = array( + 'user_id' => 2, + 'user_form_salt' => 'form-salt', + ); + $this->user->session_id = 'session-id'; + $this->user->lang = $this->language->get_lang_array(); + } + + public function test_handle_assigns_existing_template_data() + { + $request = $this->create_request_mock(); + $template = $this->createMock('\phpbb\template\template'); + $template->expects(self::once()) + ->method('assign_vars') + ->with(array( + 'S_CONSENTMANAGER_ANALYTICS' => true, + 'CONSENTMANAGER_VERSION' => 1, + 'S_ERROR' => false, + 'ERROR_MSG' => '', + 'U_ACTION' => 'adm.php?i=test', + )); + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('get_acp_template_data') + ->willReturn(array( + 'S_CONSENTMANAGER_ANALYTICS' => true, + 'CONSENTMANAGER_VERSION' => 1, + )); + + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $controller = new \phpbb\consentmanager\controller\acp_controller( + $this->language, + $consent_manager, + $log_manager, + $request, + $template + ); + $controller->set_page_url('adm.php?i=test'); + $controller->handle(); + } + + public function test_handle_submit_validation_errors_reassigns_form_data() + { + self::$valid_form = true; + + $request = $this->create_request_mock( + array( + 'submit' => 1, + 'consentmanager_analytics_enabled' => 1, + 'consentmanager_marketing_enabled' => 0, + ), + array( + 'consentmanager_integrations' => " invalid json \n", + ) + ); + $template = $this->createMock('\phpbb\template\template'); + $template->expects(self::once()) + ->method('assign_vars') + ->with(self::callback(function ($vars) { + return $vars['S_ERROR'] + && $vars['ERROR_MSG'] === 'Invalid integrations' + && $vars['U_ACTION'] === 'adm.php?i=test' + && isset($vars['CONSENTMANAGER_VERSION']); + })); + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('save_acp_settings') + ->willReturnCallback(function (array $settings, array &$errors) { + self::assertSame(array( + 'analytics_enabled' => 1, + 'marketing_enabled' => 0, + 'integrations' => 'invalid json', + ), $settings); + + $errors = array('Invalid integrations'); + return false; + }); + $consent_manager->expects(self::once()) + ->method('get_acp_template_data') + ->willReturn(array( + 'CONSENTMANAGER_VERSION' => 3, + )); + + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $log_manager->expects(self::never()) + ->method('log_admin_settings_updated'); + + $controller = new \phpbb\consentmanager\controller\acp_controller( + $this->language, + $consent_manager, + $log_manager, + $request, + $template + ); + $controller->set_page_url('adm.php?i=test'); + $controller->handle(); + } + + public function test_handle_submit_success_logs_and_triggers_success_notice() + { + self::$valid_form = true; + + $request = $this->create_request_mock( + array( + 'submit' => 1, + 'consentmanager_analytics_enabled' => 0, + 'consentmanager_marketing_enabled' => 1, + ), + array( + 'consentmanager_integrations' => '[]', + ) + ); + $template = $this->createMock('\phpbb\template\template'); + $this->setExpectedTriggerError(E_USER_NOTICE, $this->language->lang('CONFIG_UPDATED')); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('save_acp_settings') + ->willReturn(true); + + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $log_manager->expects(self::once()) + ->method('log_admin_settings_updated'); + + $controller = new \phpbb\consentmanager\controller\acp_controller( + $this->language, + $consent_manager, + $log_manager, + $request, + $template + ); + $controller->set_page_url('adm.php?i=test'); + $controller->handle(); + } + + public function test_handle_reset_consent_logs_and_triggers_success_notice() + { + self::$valid_form = true; + + $request = $this->create_request_mock(array( + 'reset_consent' => 1, + )); + $template = $this->createMock('\phpbb\template\template'); + $this->setExpectedTriggerError(E_USER_NOTICE, $this->language->lang('ACP_CONSENTMANAGER_REPROMPT_SUCCESS')); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('reset_consent_version'); + + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $log_manager->expects(self::once()) + ->method('log_admin_reprompt'); + + $controller = new \phpbb\consentmanager\controller\acp_controller( + $this->language, + $consent_manager, + $log_manager, + $request, + $template + ); + $controller->set_page_url('adm.php?i=test'); + $controller->handle(); + } + + public function test_handle_rejects_invalid_form_key() + { + self::$valid_form = false; + + $request = $this->create_request_mock(array( + 'submit' => 1, + )); + $template = $this->createMock('\phpbb\template\template'); + $this->setExpectedTriggerError(E_USER_WARNING, $this->language->lang('FORM_INVALID')); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::never()) + ->method('save_acp_settings'); + + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + + $controller = new \phpbb\consentmanager\controller\acp_controller( + $this->language, + $consent_manager, + $log_manager, + $request, + $template + ); + $controller->set_page_url('adm.php?i=test'); + $controller->handle(); + } + + protected function create_request_mock(array $values = array(), array $raw_values = array()) + { + $request = $this->getMockBuilder('\phpbb\request\request') + ->disableOriginalConstructor() + ->setMethods(array('is_set_post', 'variable', 'raw_variable')) + ->getMock(); + + $request->method('is_set_post') + ->willReturnCallback(function ($name) use ($values, $raw_values) { + return array_key_exists($name, $values) || array_key_exists($name, $raw_values); + }); + + $request->method('variable') + ->willReturnCallback(function ($name, $default) use ($values, $raw_values) { + if (array_key_exists($name, $values)) + { + return $values[$name]; + } + + if (array_key_exists($name, $raw_values)) + { + return $raw_values[$name]; + } + + return $default; + }); + + $request->method('raw_variable') + ->willReturnCallback(function ($name, $default) use ($values, $raw_values) { + if (array_key_exists($name, $raw_values)) + { + return $raw_values[$name]; + } + + if (array_key_exists($name, $values)) + { + return $values[$name]; + } + + return $default; + }); + + return $request; + } +} + +namespace phpbb\consentmanager\controller; + +function add_form_key() +{ +} + +function check_form_key() +{ + return \phpbb\consentmanager\tests\controller\acp_controller_test::$valid_form; +} + +function adm_back_link() +{ + return ''; +} diff --git a/tests/controller/log_controller_test.php b/tests/controller/log_controller_test.php new file mode 100644 index 0000000..acda086 --- /dev/null +++ b/tests/controller/log_controller_test.php @@ -0,0 +1,105 @@ +createMock('\phpbb\consentmanager\service\log_manager'), + $this->createMock('\phpbb\consentmanager\service\consent_manager_interface') + ); + + $response = $controller->log(\Symfony\Component\HttpFoundation\Request::create( + '/consent/log', + 'POST', + array(), + array(), + array(), + array(), + '{invalid' + )); + + self::assertSame(400, $response->getStatusCode()); + self::assertSame(array( + 'success' => false, + 'error' => 'invalid_payload', + ), json_decode($response->getContent(), true)); + } + + /** + * @dataProvider invalid_submission_data + */ + public function test_log_returns_service_validation_failure($submission_error, $expected_status) + { + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $log_manager->expects(self::never()) + ->method('log_consent'); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('validate_log_payload') + ->with(array('hash' => 'bad')) + ->willReturn(array( + 'success' => false, + 'error' => $submission_error, + )); + + $controller = new \phpbb\consentmanager\controller\log_controller($log_manager, $consent_manager); + $response = $controller->log(new \Symfony\Component\HttpFoundation\Request(array(), array(), array(), array(), array(), array(), json_encode(array( + 'hash' => 'bad', + )))); + + self::assertSame($expected_status, $response->getStatusCode()); + self::assertSame($submission_error, json_decode($response->getContent(), true)['error']); + } + + public function test_log_persists_valid_submission() + { + $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager'); + $log_manager->expects(self::once()) + ->method('log_consent') + ->with(array('necessary', 'analytics'), 5); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('validate_log_payload') + ->willReturn(array( + 'success' => true, + 'categories' => array('necessary', 'analytics'), + 'version' => 5, + )); + + $controller = new \phpbb\consentmanager\controller\log_controller($log_manager, $consent_manager); + $response = $controller->log(new \Symfony\Component\HttpFoundation\Request(array(), array(), array(), array(), array(), array(), json_encode(array( + 'hash' => 'good', + 'version' => 5, + 'categories' => array('analytics'), + )))); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(array( + 'success' => true, + 'categories' => array('necessary', 'analytics'), + 'version' => 5, + ), json_decode($response->getContent(), true)); + } + + public function invalid_submission_data() + { + return array( + 'invalid hash' => array('invalid_hash', 403), + 'version mismatch' => array('version_mismatch', 409), + 'generic invalid payload' => array('invalid_payload', 400), + ); + } +} diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php new file mode 100644 index 0000000..6c6b21e --- /dev/null +++ b/tests/event/listener_test.php @@ -0,0 +1,113 @@ +language = new \phpbb\language\language($lang_loader); + $this->language->add_lang('common', 'phpbb/consentmanager'); + $this->language->add_lang('common'); + + $this->user = new \phpbb\user($this->language, '\phpbb\datetime'); + $this->user->data = array( + 'user_id' => ANONYMOUS, + 'user_form_salt' => 'listener-salt', + ); + $this->user->session_id = 'listener-session'; + $user = $this->user; + } + + public function test_get_subscribed_events() + { + self::assertSame(array( + 'core.page_header_after' => 'inject_frontend', + ), \phpbb\consentmanager\event\listener::getSubscribedEvents()); + } + + public function test_inject_frontend_assigns_template_payload() + { + $helper = $this->createMock('\phpbb\controller\helper'); + $helper->expects(self::once()) + ->method('route') + ->with('phpbb_consentmanager_log_controller') + ->willReturn('/app.php/consent/log'); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('get_frontend_template_data') + ->with('/app.php/consent/log', generate_link_hash('phpbb.consentmanager.log')) + ->willReturn(array( + 'S_CONSENTMANAGER_ENABLED' => true, + 'CONSENTMANAGER_PAYLOAD' => '{"version":1}', + )); + + $template = $this->createMock('\phpbb\template\template'); + $template->expects(self::once()) + ->method('assign_vars') + ->with(array( + 'S_CONSENTMANAGER_ENABLED' => true, + 'CONSENTMANAGER_PAYLOAD' => '{"version":1}', + )); + + $listener = new \phpbb\consentmanager\event\listener( + $helper, + $this->language, + $consent_manager, + $template + ); + + $listener->inject_frontend(); + } + + public function test_inject_frontend_loads_extension_language_before_assigning_payload() + { + $helper = $this->createMock('\phpbb\controller\helper'); + $helper->expects(self::once()) + ->method('route') + ->willReturn('/app.php/consent/log'); + + $language = $this->createMock('\phpbb\language\language'); + $language->expects(self::once()) + ->method('add_lang'); + + $consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface'); + $consent_manager->expects(self::once()) + ->method('get_frontend_template_data') + ->willReturn(array()); + + $template = $this->createMock('\phpbb\template\template'); + $template->expects(self::once()) + ->method('assign_vars') + ->with(array()); + + $listener = new \phpbb\consentmanager\event\listener( + $helper, + $language, + $consent_manager, + $template + ); + + $listener->inject_frontend(); + } +} diff --git a/tests/functional/acp_test.php b/tests/functional/acp_test.php new file mode 100644 index 0000000..776d9ba --- /dev/null +++ b/tests/functional/acp_test.php @@ -0,0 +1,106 @@ +login(); + $this->admin_login(); + + $crawler = self::request('GET', $this->get_module_url()); + + $this->assertStringContainsString('Consent categories', $crawler->filter('#main')->text()); + $this->assertStringContainsString('ACP-managed integrations', $crawler->filter('#main')->text()); + $this->assertStringContainsString('Current consent version', $crawler->filter('#main')->text()); + } + + public function test_acp_form_saves_settings_and_integrations() + { + $this->login(); + $this->admin_login(); + + $crawler = self::request('GET', $this->get_module_url()); + $form = $crawler->selectButton($this->lang('SUBMIT'))->form(); + $form['consentmanager_analytics_enabled']->select('0'); + $form['consentmanager_marketing_enabled']->select('1'); + $form['consentmanager_integrations']->setValue('[{"id":"board.analytics","category":"analytics","label":"Board Analytics","src":"https://cdn.example.com/analytics.js"}]'); + + $crawler = self::submit($form); + + $this->assertStringContainsString($this->lang('CONFIG_UPDATED'), $crawler->text()); + + $sql = 'SELECT config_name, config_value + FROM ' . CONFIG_TABLE . ' + WHERE config_name IN (\'consentmanager_analytics_enabled\', \'consentmanager_marketing_enabled\')'; + $result = $this->db->sql_query($sql); + $rows = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + $config = array(); + foreach ($rows as $row) + { + $config[$row['config_name']] = $row['config_value']; + } + + $this->assertSame('0', $config['consentmanager_analytics_enabled']); + $this->assertSame('1', $config['consentmanager_marketing_enabled']); + + $sql = 'SELECT config_value + FROM ' . CONFIG_TEXT_TABLE . ' + WHERE config_name = \'consentmanager_integrations\''; + $result = $this->db->sql_query($sql); + $stored_integrations = $this->db->sql_fetchfield('config_value'); + $this->db->sql_freeresult($result); + + $this->assertSame('[{"id":"board.analytics","category":"analytics","label":"Board Analytics","src":"https://cdn.example.com/analytics.js"}]', $stored_integrations); + } + + public function test_acp_force_reprompt_increments_version() + { + $this->login(); + $this->admin_login(); + + $before = $this->get_consent_version(); + $crawler = self::request('GET', $this->get_module_url()); + $form = $crawler->selectButton('Force re-prompt')->form(); + $crawler = self::submit($form); + + $this->assertStringContainsString('Consent version increased. Visitors will be asked to review their settings again.', $crawler->text()); + $this->assertSame($before + 1, $this->get_consent_version()); + } + + protected function get_module_url() + { + return 'adm/index.php?i=%5Cphpbb%5Cconsentmanager%5Cacp%5Cconsentmanager_module&mode=settings&sid=' . $this->sid; + } + + protected function get_consent_version() + { + $sql = 'SELECT config_value + FROM ' . CONFIG_TABLE . ' + WHERE config_name = \'consentmanager_consent_version\''; + $result = $this->db->sql_query($sql); + $value = (int) $this->db->sql_fetchfield('config_value'); + $this->db->sql_freeresult($result); + + return $value; + } +} diff --git a/tests/functional/frontend_test.php b/tests/functional/frontend_test.php new file mode 100644 index 0000000..749cc6c --- /dev/null +++ b/tests/functional/frontend_test.php @@ -0,0 +1,145 @@ +reset_consent_manager_state(); + } + + public function test_frontend_markup_is_injected_on_board_pages() + { + $crawler = self::request('GET', 'index.php'); + $content = self::get_content(); + $payload = $this->extract_payload($content); + + $this->assertStringContainsString('consent-manager-root', $content); + $this->assertStringContainsString('Privacy settings', $crawler->filter('#consent-manager-link')->text()); + $this->assertSame(1, $payload['version']); + $this->assertSame('phpbb_consent_manager', $payload['storageKey']); + $this->assertSame(array('necessary'), $payload['requiredCategories']); + $this->assertContains('analytics', $payload['optionalCategories']); + $this->assertStringContainsString('app.php/consent/log', $payload['logEndpoint']); + } + + public function test_log_endpoint_rejects_invalid_json_payload() + { + $payload = $this->extract_payload(self::request('GET', 'index.php') ? self::get_content() : ''); + + self::$client->request( + 'POST', + $payload['logEndpoint'], + array(), + array(), + array('CONTENT_TYPE' => 'application/json'), + '{invalid' + ); + + $this->assertSame(400, self::$client->getResponse()->getStatus()); + $this->assertSame(array( + 'success' => false, + 'error' => 'invalid_payload', + ), json_decode(self::$client->getResponse()->getContent(), true)); + } + + public function test_log_endpoint_accepts_valid_submission_and_persists_it() + { + $payload = $this->extract_payload(self::request('GET', 'index.php') ? self::get_content() : ''); + + self::$client->request( + 'POST', + $payload['logEndpoint'], + array(), + array(), + array('CONTENT_TYPE' => 'application/json'), + json_encode(array( + 'hash' => $payload['logHash'], + 'version' => $payload['version'], + 'categories' => array('analytics', 'analytics', 'unknown'), + )) + ); + + $response = json_decode(self::$client->getResponse()->getContent(), true); + $this->assertSame(200, self::$client->getResponse()->getStatus()); + $this->assertSame(array('necessary', 'analytics'), $response['categories']); + $this->assertSame($payload['version'], $response['version']); + + $sql = 'SELECT consent_version, accepted_categories + FROM phpbb_consentmanager_logs + ORDER BY consent_log_id DESC'; + $result = $this->db->sql_query_limit($sql, 1); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + $this->assertSame((int) $payload['version'], (int) $row['consent_version']); + $this->assertSame('["necessary","analytics"]', $row['accepted_categories']); + } + + public function test_log_endpoint_rejects_stale_version() + { + $payload = $this->extract_payload(self::request('GET', 'index.php') ? self::get_content() : ''); + + self::$client->request( + 'POST', + $payload['logEndpoint'], + array(), + array(), + array('CONTENT_TYPE' => 'application/json'), + json_encode(array( + 'hash' => $payload['logHash'], + 'version' => $payload['version'] + 1, + 'categories' => array('analytics'), + )) + ); + + $response = json_decode(self::$client->getResponse()->getContent(), true); + $this->assertSame(409, self::$client->getResponse()->getStatus()); + $this->assertSame('version_mismatch', $response['error']); + } + + protected function extract_payload($content) + { + preg_match('/var payload = (.*?);\s*var requiredCategories/s', $content, $matches); + $this->assertNotEmpty($matches[1]); + + return json_decode($matches[1], true); + } + + protected function reset_consent_manager_state() + { + $this->db->sql_query('UPDATE ' . CONFIG_TABLE . " + SET config_value = '1' + WHERE " . $this->db->sql_in_set('config_name', array( + 'consentmanager_analytics_enabled', + 'consentmanager_marketing_enabled', + 'consentmanager_consent_version', + )) + ); + $this->db->sql_query('UPDATE ' . CONFIG_TEXT_TABLE . " + SET config_value = '' + WHERE config_name = 'consentmanager_integrations'"); + $this->db->sql_query('DELETE FROM phpbb_consentmanager_logs'); + + $this->purge_cache(); + } +} diff --git a/tests/service/consent_manager_test.php b/tests/service/consent_manager_test.php new file mode 100644 index 0000000..de34749 --- /dev/null +++ b/tests/service/consent_manager_test.php @@ -0,0 +1,552 @@ +phpbb_root_path = $phpbb_root_path; + $this->php_ext = $phpEx; + + $lang_loader = new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx); + $this->language = new \phpbb\language\language($lang_loader); + $this->language->add_lang('common', 'phpbb/consentmanager'); + $this->language->add_lang('acp_consentmanager', 'phpbb/consentmanager'); + + $this->filesystem = new \phpbb\filesystem\filesystem(); + + $request = new \phpbb_mock_request(array(), array(), array(), array( + 'HTTP_HOST' => 'example.com', + 'REQUEST_URI' => '/index.php', + 'SCRIPT_NAME' => '/index.php', + )); + $symfony_request = new \phpbb\symfony_request($request); + $this->path_helper = new \phpbb\path_helper( + $symfony_request, + $this->filesystem, + $request, + $phpbb_root_path, + $phpEx + ); + + $user = new \phpbb_mock_user(); + $user->data = array( + 'user_id' => ANONYMOUS, + 'user_form_salt' => 'consent-salt', + ); + } + + public function test_public_metadata_methods() + { + $manager = $this->get_manager(array( + 'consentmanager_analytics_enabled' => 1, + 'consentmanager_marketing_enabled' => 0, + 'consentmanager_consent_version' => 7, + )); + + self::assertSame('phpbb_consent_manager', $manager->get_storage_key()); + self::assertSame('phpbb_consent_manager', $manager->get_cookie_name()); + self::assertSame(7, $manager->get_version()); + self::assertTrue($manager->is_supported_category('analytics')); + self::assertFalse($manager->is_supported_category('foobar')); + self::assertTrue($manager->is_category_enabled('analytics')); + self::assertFalse($manager->is_category_enabled('marketing')); + + $categories = $manager->get_categories(); + self::assertSame('necessary', $categories['necessary']['id']); + self::assertTrue($categories['necessary']['required']); + self::assertTrue($categories['necessary']['enabled']); + } + + /** + * @dataProvider invalid_registration_data + */ + public function test_register_rejects_invalid_definitions($id, array $definition) + { + $manager = $this->get_manager(); + + self::assertFalse($manager->register($id, $definition)); + self::assertSame(array(), $manager->get_services()); + } + + public function invalid_registration_data() + { + return array( + 'invalid id' => array('bad id', array( + 'category' => 'analytics', + 'src' => 'https://cdn.example.com/script.js', + )), + 'unsupported category' => array('vendor.bundle', array( + 'category' => 'ads', + 'src' => 'https://cdn.example.com/script.js', + )), + ); + } + + /** + * @dataProvider invalid_script_source_data + */ + public function test_register_discards_invalid_script_sources(array $definition) + { + $manager = $this->get_manager(); + + self::assertTrue($manager->register('vendor.bundle', $definition)); + self::assertSame(array(), $this->get_service('vendor.bundle', $manager)['scripts']); + } + + public function invalid_script_source_data() + { + return array( + 'multiple sources' => array(array( + 'category' => 'analytics', + 'src' => 'https://cdn.example.com/script.js', + 'inline' => 'console.log("bad");', + )), + 'unsafe remote source' => array(array( + 'category' => 'analytics', + 'src' => 'javascript:alert(1)', + )), + 'invalid local asset' => array(array( + 'category' => 'analytics', + 'asset' => 'https://cdn.example.com/script.js', + )), + ); + } + + public function test_register_normalizes_scripts_and_strips_unsafe_attributes() + { + $manager = $this->get_manager(); + + self::assertTrue($manager->register('vendor.bundle', array( + 'category' => 'analytics', + 'label' => ' Vendor Bundle ', + 'description' => ' Deferred scripts ', + 'scripts' => array( + array( + 'src' => 'https://cdn.example.com/a.js', + 'async' => false, + 'attributes' => array( + 'data-site' => 123, + 'onload' => 'evil()', + 'src' => 'https://ignored.example.com', + ), + ), + array( + 'inline' => 'window.testConsent = true;', + 'wait_for_dom_ready' => 1, + 'attributes' => array( + 'data-inline' => 'ok', + 'type' => 'ignored', + ), + ), + array( + 'src' => 'javascript:alert(1)', + ), + ), + ))); + + $service = $this->get_service('vendor.bundle', $manager); + + self::assertSame('Vendor Bundle', $service['label']); + self::assertSame('Deferred scripts', $service['description']); + self::assertCount(2, $service['scripts']); + + self::assertSame('vendor.bundle.1', $service['scripts'][0]['id']); + self::assertSame('https://cdn.example.com/a.js', $service['scripts'][0]['src']); + self::assertFalse($service['scripts'][0]['async']); + self::assertSame(array('data-site' => '123'), $service['scripts'][0]['attributes']); + + self::assertSame('vendor.bundle.2', $service['scripts'][1]['id']); + self::assertSame('', $service['scripts'][1]['src']); + self::assertSame('window.testConsent = true;', $service['scripts'][1]['inline']); + self::assertTrue($service['scripts'][1]['wait_for_dom_ready']); + self::assertSame(array('data-inline' => 'ok'), $service['scripts'][1]['attributes']); + } + + public function test_register_resolves_local_assets() + { + $manager = $this->get_manager(); + + self::assertTrue($manager->register('vendor.asset', array( + 'category' => 'analytics', + 'asset' => './ext/phpbb/consentmanager/styles/all/template/js/consentmanager.js', + ))); + + $script = $this->get_service('vendor.asset', $manager)['scripts'][0]; + + self::assertStringContainsString('ext/phpbb/consentmanager/styles/all/template/js/consentmanager.js', $script['src']); + self::assertStringContainsString('assets_version=42', $script['src']); + self::assertTrue($script['async']); + } + + public function test_build_frontend_payload_collects_registered_and_configured_integrations() + { + $dispatcher = $this->get_collect_registrations_dispatcher(function ($data) { + $data['consent_manager']->register('vendor.bundle', array( + 'category' => 'analytics', + 'label' => 'Vendor bundle', + 'scripts' => array( + array('src' => 'https://cdn.example.com/analytics.js', 'category' => 'analytics'), + array('src' => 'https://cdn.example.com/marketing.js', 'category' => 'marketing'), + ), + )); + + return $data; + }); + + $manager = $this->get_manager(array( + 'consentmanager_marketing_enabled' => 0, + 'consentmanager_consent_version' => 7, + ), $this->get_submitted_integrations_json(), $dispatcher); + + $payload = $manager->build_frontend_payload('/app.php/consent/log', 'deadbeef'); + + self::assertSame(array('necessary'), $payload['requiredCategories']); + self::assertSame(array('necessary', 'analytics'), $payload['enabledCategories']); + self::assertSame(array('analytics'), $payload['optionalCategories']); + self::assertSame('/app.php/consent/log', $payload['logEndpoint']); + self::assertSame('deadbeef', $payload['logHash']); + self::assertSame(array('vendor.bundle', 'board.analytics'), array_column($payload['services'], 'id')); + self::assertSame(array('vendor.bundle.1', 'board.analytics'), array_column($payload['scripts'], 'id')); + } + + public function test_get_frontend_template_data_returns_json_payload() + { + $manager = $this->get_manager(); + $data = $manager->get_frontend_template_data('/app.php/consent/log?x=', 'abc123'); + $payload = json_decode($data['CONSENTMANAGER_PAYLOAD'], true); + + self::assertTrue($data['S_CONSENTMANAGER_ENABLED']); + self::assertTrue($data['S_CONSENTMANAGER_ANALYTICS_ENABLED']); + self::assertTrue($data['S_CONSENTMANAGER_MARKETING_ENABLED']); + self::assertSame('/app.php/consent/log?x=', $payload['logEndpoint']); + self::assertSame('abc123', $payload['logHash']); + self::assertSame($this->language->lang('CONSENTMANAGER_SETTINGS_TITLE'), $payload['strings']['settingsTitle']); + } + + public function test_get_configured_integrations_normalizes_stored_data() + { + $manager = $this->get_manager(array(), $this->get_submitted_integrations_json()); + $integration = $manager->get_configured_integrations()[0]; + + self::assertSame('board.analytics', $integration['id']); + self::assertSame('Board Analytics', $integration['label']); + self::assertSame('analytics', $integration['category']); + self::assertSame('Loads a simple analytics library after consent.', $integration['description']); + self::assertSame('https://cdn.example.com/analytics.js', $integration['scripts'][0]['src']); + self::assertTrue($integration['scripts'][0]['async']); + self::assertFalse($integration['scripts'][0]['defer']); + } + + public function test_get_configured_integrations_returns_empty_array_for_invalid_data() + { + self::assertSame(array(), $this->get_manager(array(), '{not json')->get_configured_integrations()); + } + + public function test_get_acp_template_data_pretty_prints_stored_integrations() + { + $template_data = $this->get_manager(array(), $this->get_submitted_integrations_json())->get_acp_template_data(); + + self::assertSame($this->get_pretty_integrations_json(), $template_data['CONSENTMANAGER_INTEGRATIONS']); + self::assertSame(1, $template_data['CONSENTMANAGER_VERSION']); + } + + public function test_get_acp_template_data_keeps_invalid_json_verbatim() + { + $template_data = $this->get_manager(array(), '{not json')->get_acp_template_data(); + + self::assertSame('{not json', $template_data['CONSENTMANAGER_INTEGRATIONS']); + } + + public function test_save_acp_settings_updates_flags_and_integrations() + { + $config_text = $this->createMock('\phpbb\config\db_text'); + $config_text->expects(self::once()) + ->method('set') + ->with('consentmanager_integrations', trim($this->get_pretty_integrations_json())); + + $manager = $this->get_manager(array(), '', null, $config_text); + $errors = array(); + + self::assertTrue($manager->save_acp_settings(array( + 'analytics_enabled' => 0, + 'marketing_enabled' => 1, + 'integrations' => $this->get_pretty_integrations_json(), + ), $errors)); + self::assertSame(array(), $errors); + self::assertFalse($manager->is_category_enabled('analytics')); + self::assertTrue($manager->is_category_enabled('marketing')); + } + + public function test_save_acp_settings_stores_empty_integrations_as_empty_string() + { + $config_text = $this->createMock('\phpbb\config\db_text'); + $config_text->expects(self::once()) + ->method('set') + ->with('consentmanager_integrations', ''); + + $manager = $this->get_manager(array(), '', null, $config_text); + $errors = array(); + + self::assertTrue($manager->save_acp_settings(array( + 'analytics_enabled' => 1, + 'marketing_enabled' => 0, + 'integrations' => '', + ), $errors)); + self::assertSame(array(), $errors); + } + + /** + * @dataProvider invalid_integrations_data + */ + public function test_save_acp_settings_rejects_invalid_integrations($json) + { + $config_text = $this->createMock('\phpbb\config\db_text'); + $config_text->expects(self::never()) + ->method('set'); + + $manager = $this->get_manager(array(), '', null, $config_text); + $errors = array(); + + self::assertFalse($manager->save_acp_settings(array( + 'analytics_enabled' => 1, + 'marketing_enabled' => 1, + 'integrations' => $json, + ), $errors)); + self::assertSame(array($this->language->lang('ACP_CONSENTMANAGER_INVALID_INTEGRATIONS')), $errors); + } + + public function invalid_integrations_data() + { + return array( + 'malformed json' => array('{not json'), + 'top level object' => array($this->get_non_array_integrations_json()), + ); + } + + public function test_normalize_integrations_reports_invalid_entries_and_keeps_last_duplicate() + { + $manager = $this->get_manager(); + $errors = array(); + + $integrations = $manager->normalize_integrations(array( + array( + 'id' => 'board.analytics', + 'category' => 'analytics', + 'label' => 'Old label', + 'src' => 'https://cdn.example.com/one.js', + ), + array( + 'id' => 'bad id', + 'category' => 'analytics', + 'src' => 'https://cdn.example.com/two.js', + ), + array( + 'id' => 'board.analytics', + 'category' => 'analytics', + 'label' => 'New label', + 'src' => 'https://cdn.example.com/three.js', + 'defer' => true, + ), + ), $errors); + + self::assertCount(1, $integrations); + self::assertSame('New label', $integrations[0]['label']); + self::assertSame('https://cdn.example.com/three.js', $integrations[0]['scripts'][0]['src']); + self::assertTrue($integrations[0]['scripts'][0]['defer']); + self::assertSame( + array($this->language->lang('ACP_CONSENTMANAGER_INVALID_INTEGRATION_ENTRY', 2)), + $errors + ); + } + + public function test_normalize_categories_keeps_required_and_enabled_categories() + { + $manager = $this->get_manager(array( + 'consentmanager_marketing_enabled' => 0, + )); + + self::assertSame( + array('necessary', 'analytics'), + $manager->normalize_categories(array('analytics', 'necessary', 'marketing', 'analytics', 'unknown')) + ); + } + + public function test_validate_log_payload_accepts_valid_hash_and_normalizes_categories() + { + $manager = $this->get_manager(array( + 'consentmanager_marketing_enabled' => 0, + 'consentmanager_consent_version' => 4, + )); + + $submission = $manager->validate_log_payload(array( + 'hash' => generate_link_hash('phpbb.consentmanager.log'), + 'version' => 4, + 'categories' => array('analytics', 'necessary', 'marketing', 'analytics'), + )); + + self::assertTrue($submission['success']); + self::assertSame(array('necessary', 'analytics'), $submission['categories']); + self::assertSame(4, $submission['version']); + } + + public function test_validate_log_payload_rejects_invalid_hash() + { + $submission = $this->get_manager()->validate_log_payload(array( + 'hash' => 'deadbeef', + 'version' => 1, + 'categories' => array('analytics'), + )); + + self::assertSame(array( + 'success' => false, + 'error' => 'invalid_hash', + ), $submission); + } + + public function test_validate_log_payload_rejects_version_mismatch() + { + $submission = $this->get_manager(array( + 'consentmanager_consent_version' => 9, + ))->validate_log_payload(array( + 'hash' => generate_link_hash('phpbb.consentmanager.log'), + 'version' => 1, + 'categories' => array('analytics'), + )); + + self::assertSame(array( + 'success' => false, + 'error' => 'version_mismatch', + ), $submission); + } + + protected function get_manager(array $config_values = array(), $stored_integrations = '', $dispatcher = null, $config_text = null) + { + if ($config_text === null) + { + $config_text = $this->get_config_text($stored_integrations); + } + + if ($dispatcher === null) + { + $dispatcher = new \phpbb_mock_event_dispatcher(); + } + + $config = new \phpbb\config\config(array_merge(array( + 'consentmanager_analytics_enabled' => 1, + 'consentmanager_marketing_enabled' => 1, + 'consentmanager_consent_version' => 1, + 'assets_version' => '42', + 'rand_seed' => 'seed', + ), $config_values)); + + $twig_environment = $this->getMockBuilder('\phpbb\template\twig\environment') + ->disableOriginalConstructor() + ->setMethods(array('get_phpbb_root_path', 'findTemplate')) + ->getMock(); + $twig_environment->method('get_phpbb_root_path') + ->willReturn($this->phpbb_root_path); + + return new \phpbb\consentmanager\service\consent_manager( + $config, + $config_text, + $this->language, + $dispatcher, + $twig_environment, + $this->path_helper, + $this->filesystem + ); + } + + protected function get_config_text($stored_integrations = '') + { + $config_text = $this->createMock('\phpbb\config\db_text'); + $config_text->method('get') + ->willReturnMap(array( + array('consentmanager_integrations', $stored_integrations), + )); + + return $config_text; + } + + protected function get_service($id, \phpbb\consentmanager\service\consent_manager $manager) + { + $services = $manager->get_services(); + self::assertArrayHasKey($id, $services); + + return $services[$id]; + } + + protected function get_collect_registrations_dispatcher(callable $callback) + { + $dispatcher = $this->createMock('phpbb\\event\\dispatcher_interface'); + $dispatcher->expects(self::once()) + ->method('trigger_event') + ->with( + 'phpbb.consentmanager.collect_registrations', + $this->callback(function ($vars) { + return isset($vars['consent_manager']) + && $vars['consent_manager'] instanceof \phpbb\consentmanager\service\consent_manager; + }) + ) + ->willReturnCallback(function ($event_name, $data = array()) use ($callback) { + return $callback($data); + }); + + return $dispatcher; + } + + protected function get_submitted_integrations_json() + { + return '[{"id":"board.analytics","category":"analytics","label":"Board Analytics","description":"Loads a simple analytics library after consent.","src":"https://cdn.example.com/analytics.js","async":true}]'; + } + + protected function get_pretty_integrations_json() + { + return <<<'JSON' +[ + { + "id": "board.analytics", + "category": "analytics", + "label": "Board Analytics", + "description": "Loads a simple analytics library after consent.", + "src": "https://cdn.example.com/analytics.js", + "async": true + } +] +JSON; + } + + protected function get_non_array_integrations_json() + { + return '{"id":"board.analytics","category":"analytics","label":"Board Analytics","description":"Loads a simple analytics library after consent.","src":"https://cdn.example.com/analytics.js","async":true}'; + } +} diff --git a/tests/service/log_manager_test.php b/tests/service/log_manager_test.php new file mode 100644 index 0000000..0723fb2 --- /dev/null +++ b/tests/service/log_manager_test.php @@ -0,0 +1,129 @@ +language = new \phpbb\language\language($lang_loader); + + $db = $this->new_dbal(); + $db->sql_query('DELETE FROM phpbb_consentmanager_logs'); + $db->sql_close(); + } + + public function getDataSet() + { + return new \PHPUnit\DbUnit\DataSet\DefaultDataSet(); + } + + public function test_log_consent_persists_authenticated_subject() + { + $manager = $this->create_manager(42, 'ignored-session'); + $manager->log_consent(array('necessary', 'analytics'), 3); + + $this->assertSqlResultEquals(array( + array( + 'anonymized_id' => hash_hmac('sha256', 'u:42', 'random-seed'), + 'consent_version' => '3', + 'accepted_categories' => '["necessary","analytics"]', + ), + ), 'SELECT anonymized_id, consent_version, accepted_categories + FROM phpbb_consentmanager_logs'); + } + + public function test_log_consent_uses_session_identifier_for_guests() + { + $manager = $this->create_manager(ANONYMOUS, 'guest-session'); + $manager->log_consent(array('necessary'), 9); + + $this->assertSqlResultEquals(array( + array( + 'anonymized_id' => hash_hmac('sha256', 's:guest-session', 'random-seed'), + 'consent_version' => '9', + 'accepted_categories' => '["necessary"]', + ), + ), 'SELECT anonymized_id, consent_version, accepted_categories + FROM phpbb_consentmanager_logs'); + } + + public function test_log_admin_settings_updated_delegates_to_phpbb_log() + { + $log = $this->getMockBuilder('\phpbb\log\log') + ->disableOriginalConstructor() + ->setMethods(array('add')) + ->getMock(); + $log->expects(self::once()) + ->method('add') + ->with('admin', 7, '127.0.0.1', 'LOG_CONSENTMANAGER_UPDATED'); + + $manager = $this->create_manager(7, 'admin-session', $log); + $manager->log_admin_settings_updated(); + } + + public function test_log_admin_reprompt_delegates_to_phpbb_log() + { + $log = $this->getMockBuilder('\phpbb\log\log') + ->disableOriginalConstructor() + ->setMethods(array('add')) + ->getMock(); + $log->expects(self::once()) + ->method('add') + ->with('admin', 7, '127.0.0.1', 'LOG_CONSENTMANAGER_REPROMPT'); + + $manager = $this->create_manager(7, 'admin-session', $log); + $manager->log_admin_reprompt(); + } + + protected function create_manager($user_id, $session_id, $log = null) + { + $config = new \phpbb\config\config(array( + 'rand_seed' => 'random-seed', + )); + $db = $this->new_dbal(); + + if ($log === null) + { + $log = $this->getMockBuilder('\phpbb\log\log') + ->disableOriginalConstructor() + ->getMock(); + } + + $user = new \phpbb\user($this->language, '\phpbb\datetime'); + $user->data = array( + 'user_id' => $user_id, + ); + $user->session_id = $session_id; + $user->ip = '127.0.0.1'; + + return new \phpbb\consentmanager\service\log_manager( + $config, + $db, + $log, + $user, + 'phpbb_consentmanager_logs' + ); + } +}