diff --git a/acp/consentmanager_info.php b/acp/consentmanager_info.php
index 357ac61..5506396 100644
--- a/acp/consentmanager_info.php
+++ b/acp/consentmanager_info.php
@@ -23,6 +23,11 @@ public function module()
'auth' => 'ext_phpbb/consentmanager && acl_a_board',
'cat' => ['ACP_CONSENTMANAGER'],
],
+ 'export' => [
+ 'title' => 'ACP_CONSENTMANAGER_EXPORT',
+ 'auth' => 'ext_phpbb/consentmanager && acl_a_board',
+ 'cat' => ['ACP_CONSENTMANAGER'],
+ ],
],
];
}
diff --git a/acp/consentmanager_module.php b/acp/consentmanager_module.php
index 14530c9..e03c307 100644
--- a/acp/consentmanager_module.php
+++ b/acp/consentmanager_module.php
@@ -24,9 +24,19 @@ public function main($id, $mode)
$controller = $phpbb_container->get('phpbb.consentmanager.controller.acp');
$controller->set_page_url($this->u_action);
- $this->tpl_name = 'consentmanager_acp';
- $this->page_title = 'ACP_CONSENTMANAGER';
+ switch ($mode)
+ {
+ case 'export':
+ $this->tpl_name = 'consentmanager_acp_export';
+ $this->page_title = 'ACP_CONSENTMANAGER_EXPORT';
+ $controller->handle_export();
+ break;
- $controller->handle();
+ default:
+ $this->tpl_name = 'consentmanager_acp';
+ $this->page_title = 'ACP_CONSENTMANAGER';
+ $controller->handle();
+ break;
+ }
}
}
diff --git a/adm/style/consentmanager_acp_export.html b/adm/style/consentmanager_acp_export.html
new file mode 100644
index 0000000..311a08d
--- /dev/null
+++ b/adm/style/consentmanager_acp_export.html
@@ -0,0 +1,50 @@
+{% include 'overall_header.html' %}
+
+
{{ lang('ACP_CONSENTMANAGER') }}
+
+{{ lang('ACP_CONSENTMANAGER_EXPORT_EXPLAIN') }}
+
+{% if S_ERROR %}
+
+
{{ lang('WARNING') }}
+
{{ ERROR_MSG }}
+
+{% endif %}
+
+
+
+{% include 'overall_footer.html' %}
diff --git a/config/services.yml b/config/services.yml
index 2719831..dfa23ca 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -15,6 +15,14 @@ services:
phpbb.consentmanager.log_manager:
class: phpbb\consentmanager\service\log_manager
+ arguments:
+ - '@config'
+ - '@dbal.conn'
+ - '@user'
+ - '%tables.phpbb.consentmanager.consent_logs%'
+
+ phpbb.consentmanager.acp_manager:
+ class: phpbb\consentmanager\service\acp_manager
arguments:
- '@config'
- '@dbal.conn'
@@ -37,7 +45,7 @@ services:
arguments:
- '@language'
- '@phpbb.consentmanager.service'
- - '@phpbb.consentmanager.log_manager'
+ - '@phpbb.consentmanager.acp_manager'
- '@request'
- '@template'
diff --git a/controller/acp_controller.php b/controller/acp_controller.php
index 38f8783..00bf85b 100644
--- a/controller/acp_controller.php
+++ b/controller/acp_controller.php
@@ -10,8 +10,8 @@
namespace phpbb\consentmanager\controller;
+use phpbb\consentmanager\service\acp_manager;
use phpbb\consentmanager\service\consent_manager_interface;
-use phpbb\consentmanager\service\log_manager;
use phpbb\language\language;
use phpbb\request\request;
use phpbb\template\template;
@@ -24,8 +24,8 @@ class acp_controller
/** @var consent_manager_interface */
protected $consent_manager;
- /** @var log_manager */
- protected $log_manager;
+ /** @var acp_manager */
+ protected $acp_manager;
/** @var request */
protected $request;
@@ -41,15 +41,15 @@ class acp_controller
*
* @param language $language Language service
* @param consent_manager_interface $consent_manager Consent manager service
- * @param log_manager $log_manager Consent log manager
+ * @param acp_manager $acp_manager ACP manager service
* @param request $request Request service
* @param template $template Template service
*/
- public function __construct(language $language, consent_manager_interface $consent_manager, log_manager $log_manager, request $request, template $template)
+ public function __construct(language $language, consent_manager_interface $consent_manager, acp_manager $acp_manager, request $request, template $template)
{
$this->language = $language;
$this->consent_manager = $consent_manager;
- $this->log_manager = $log_manager;
+ $this->acp_manager = $acp_manager;
$this->request = $request;
$this->template = $template;
@@ -79,7 +79,7 @@ public function handle()
if ($this->request->is_set_post('submit'))
{
- $this->validate_form_key();
+ $this->validate_form_key('phpbb_consentmanager_acp');
$errors = [];
$saved = $this->consent_manager->save_acp_settings([
@@ -94,16 +94,16 @@ public function handle()
return;
}
- $this->log_manager->log_admin_settings_updated();
+ $this->acp_manager->log_admin_settings_updated();
trigger_error($this->language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action));
}
if ($this->request->is_set_post('reset_consent'))
{
- $this->validate_form_key();
+ $this->validate_form_key('phpbb_consentmanager_acp');
$this->consent_manager->reset_consent_version();
- $this->log_manager->log_admin_reprompt();
+ $this->acp_manager->log_admin_reprompt();
trigger_error($this->language->lang('ACP_CONSENTMANAGER_REPROMPT_SUCCESS') . adm_back_link($this->u_action));
}
@@ -112,12 +112,131 @@ public function handle()
}
/**
- * Assign consent manager settings to the ACP template.
+ * Handle the ACP export page request.
*
- * @param array $errors Validation errors to display
+ * Displays the filter form on GET. On POST with download_csv, streams a
+ * CSV file of consent log records matching the supplied filters.
*
* @return void
*/
+ public function handle_export()
+ {
+ add_form_key('phpbb_consentmanager_export');
+
+ if ($this->request->is_set_post('download_csv'))
+ {
+ $this->validate_form_key('phpbb_consentmanager_export');
+
+ $errors = [];
+ $filters = $this->parse_export_filters($errors);
+
+ if (!empty($errors))
+ {
+ $this->assign_export_template_vars($errors);
+ return;
+ }
+
+ $this->acp_manager->log_admin_export();
+ $this->send_csv_download($filters);
+ }
+
+ $this->assign_export_template_vars();
+ }
+
+ /**
+ * Parse and validate filter inputs from the export form.
+ *
+ * @param array $errors Reference — validation errors are appended here
+ *
+ * @return array Validated filter map (date_from, date_to, user_id, consent_version)
+ */
+ protected function parse_export_filters(array &$errors)
+ {
+ $date_from_str = trim($this->request->variable('export_date_from', ''));
+ $date_to_str = trim($this->request->variable('export_date_to', ''));
+ $user_id = $this->request->variable('export_user_id', 0);
+ $consent_ver = $this->request->variable('export_consent_version', 0);
+
+ $filters = [];
+ $date_from = $this->acp_manager->parse_date_filter($date_from_str);
+ $date_to = $this->acp_manager->parse_date_filter($date_to_str, true);
+
+ if ($date_from_str !== '' && $date_from === false)
+ {
+ $errors[] = $this->language->lang('ACP_CONSENTMANAGER_EXPORT_INVALID_DATE_FROM');
+ }
+
+ if ($date_to_str !== '' && $date_to === false)
+ {
+ $errors[] = $this->language->lang('ACP_CONSENTMANAGER_EXPORT_INVALID_DATE_TO');
+ }
+
+ if ($date_from !== false && $date_to !== false && $date_from > $date_to)
+ {
+ $errors[] = $this->language->lang('ACP_CONSENTMANAGER_EXPORT_DATE_RANGE_INVALID');
+ }
+
+ if (empty($errors))
+ {
+ if ($date_from !== false)
+ {
+ $filters['date_from'] = $date_from;
+ }
+
+ if ($date_to !== false)
+ {
+ $filters['date_to'] = $date_to;
+ }
+ }
+
+ if ($user_id > 0)
+ {
+ $filters['user_id'] = $user_id;
+ }
+
+ if ($consent_ver > 0)
+ {
+ $filters['consent_version'] = $consent_ver;
+ }
+
+ return $filters;
+ }
+
+ protected function send_csv_download(array $filters)
+ {
+ if (ob_get_level())
+ {
+ ob_end_clean();
+ }
+
+ header('Content-Type: text/csv; charset=UTF-8');
+ header('Content-Disposition: attachment; filename="consent_logs.csv"');
+ header('Cache-Control: no-cache, no-store, must-revalidate');
+ header('Pragma: no-cache');
+ header('Expires: 0');
+
+ $handle = fopen('php://output', 'w');
+ fwrite($handle, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel compatibility
+ fputcsv($handle, ['anonymized_id', 'timestamp', 'consent_version', 'categories']);
+ $this->acp_manager->stream_logs_csv($handle, $filters);
+ fclose($handle);
+
+ exit;
+ }
+
+ protected function assign_export_template_vars(array $errors = [])
+ {
+ $this->template->assign_vars([
+ 'S_ERROR' => !empty($errors),
+ 'ERROR_MSG' => implode('
', $errors),
+ 'EXPORT_DATE_FROM' => $this->request->variable('export_date_from', ''),
+ 'EXPORT_DATE_TO' => $this->request->variable('export_date_to', ''),
+ 'EXPORT_USER_ID' => $this->request->variable('export_user_id', 0),
+ 'EXPORT_CONSENT_VER' => $this->request->variable('export_consent_version', 0),
+ 'U_ACTION' => $this->u_action,
+ ]);
+ }
+
protected function assign_template_vars(array $errors = [])
{
$this->template->assign_vars(array_merge(
@@ -130,14 +249,9 @@ protected function assign_template_vars(array $errors = [])
));
}
- /**
- * Ensure the ACP form key is valid before processing changes.
- *
- * @return void
- */
- protected function validate_form_key()
+ protected function validate_form_key($form_key)
{
- if (!check_form_key('phpbb_consentmanager_acp'))
+ if (!check_form_key($form_key))
{
trigger_error($this->language->lang('FORM_INVALID') . adm_back_link($this->u_action), E_USER_WARNING);
}
diff --git a/language/en/acp_consentmanager.php b/language/en/acp_consentmanager.php
index cde21f8..b21eef1 100644
--- a/language/en/acp_consentmanager.php
+++ b/language/en/acp_consentmanager.php
@@ -34,4 +34,19 @@
'ACP_CONSENTMANAGER_INVALID_INTEGRATIONS' => 'The integrations field must contain a valid JSON array.',
'ACP_CONSENTMANAGER_INVALID_INTEGRATION_ENTRY' => 'Integration entry %1$s is invalid. Each entry must include a safe id, supported category, and valid script source URL.',
'EXAMPLE' => 'Example',
+
+ // Export consent logs
+ 'ACP_CONSENTMANAGER_EXPORT_EXPLAIN' => 'Download a CSV file of stored consent log records. All fields are optional; leave them blank to export the full log.',
+ 'ACP_CONSENTMANAGER_EXPORT_FILTERS' => 'Export filters',
+ 'ACP_CONSENTMANAGER_EXPORT_DATE_FROM' => 'Date from',
+ 'ACP_CONSENTMANAGER_EXPORT_DATE_TO' => 'Date to',
+ 'ACP_CONSENTMANAGER_EXPORT_DATE_EXPLAIN' => 'Use the browser date picker when available. If you cannot pick a date, enter it in YYYY-MM-DD format. Dates are interpreted in UTC. Leave blank to omit this boundary.',
+ 'ACP_CONSENTMANAGER_EXPORT_USER_ID' => 'User ID',
+ 'ACP_CONSENTMANAGER_EXPORT_USER_ID_EXPLAIN' => 'Enter a registered user ID to restrict the export to that user\'s consent records. Leave blank to include all users. Note: records for guests use a session-based identifier and cannot be filtered by user ID.',
+ 'ACP_CONSENTMANAGER_EXPORT_VERSION' => 'Consent version',
+ 'ACP_CONSENTMANAGER_EXPORT_VERSION_EXPLAIN' => 'Restrict the export to a specific consent version. Leave blank for all versions.',
+ 'ACP_CONSENTMANAGER_EXPORT_DOWNLOAD' => 'Download CSV',
+ 'ACP_CONSENTMANAGER_EXPORT_INVALID_DATE_FROM' => 'The "Date from" value is not a valid date. Use the browser date picker when available, or enter the date in YYYY-MM-DD format.',
+ 'ACP_CONSENTMANAGER_EXPORT_INVALID_DATE_TO' => 'The "Date to" value is not a valid date. Use the browser date picker when available, or enter the date in YYYY-MM-DD format.',
+ 'ACP_CONSENTMANAGER_EXPORT_DATE_RANGE_INVALID' => '"Date from" must not be later than "Date to".',
]);
diff --git a/language/en/info_acp_consentmanager.php b/language/en/info_acp_consentmanager.php
index 60112a5..cf531c9 100644
--- a/language/en/info_acp_consentmanager.php
+++ b/language/en/info_acp_consentmanager.php
@@ -21,6 +21,8 @@
$lang = array_merge($lang, [
'ACP_CONSENTMANAGER' => 'Consent Manager',
'ACP_CONSENTMANAGER_SETTINGS' => 'Settings',
+ 'ACP_CONSENTMANAGER_EXPORT' => 'Export Consent Logs',
'LOG_CONSENTMANAGER_UPDATED' => 'Updated Consent Manager settings',
'LOG_CONSENTMANAGER_REPROMPT' => 'Forced Consent Manager re-prompt by increasing the consent version',
+ 'LOG_CONSENTMANAGER_EXPORT' => 'Exported Consent Manager logs as CSV',
]);
diff --git a/migrations/m1_initial.php b/migrations/m1_initial.php
index 959f517..835dcb1 100644
--- a/migrations/m1_initial.php
+++ b/migrations/m1_initial.php
@@ -63,7 +63,7 @@ public function update_data()
['module.add', ['acp', 'ACP_CAT_DOT_MODS', 'ACP_CONSENTMANAGER']],
['module.add', ['acp', 'ACP_CONSENTMANAGER', [
'module_basename' => '\phpbb\consentmanager\acp\consentmanager_module',
- 'modes' => ['settings'],
+ 'modes' => ['settings', 'export'],
]]],
];
}
@@ -75,6 +75,7 @@ public function revert_data()
['config.remove', ['consentmanager_marketing_enabled']],
['config.remove', ['consentmanager_consent_version']],
['config_text.remove', ['consentmanager_integrations']],
+ ['module.remove', ['acp', 'ACP_CONSENTMANAGER', 'ACP_CONSENTMANAGER_EXPORT']],
['module.remove', ['acp', 'ACP_CONSENTMANAGER', 'ACP_CONSENTMANAGER_SETTINGS']],
['module.remove', ['acp', 'ACP_CAT_DOT_MODS', 'ACP_CONSENTMANAGER']],
];
diff --git a/service/acp_manager.php b/service/acp_manager.php
new file mode 100644
index 0000000..e6f360f
--- /dev/null
+++ b/service/acp_manager.php
@@ -0,0 +1,224 @@
+config = $config;
+ $this->db = $db;
+ $this->log = $log;
+ $this->user = $user;
+ $this->consent_logs_table = $consent_logs_table;
+ }
+
+ /**
+ * Parse a YYYY-MM-DD date string into a UTC timestamp.
+ *
+ * @param string $date_str Input date string
+ * @param bool $end_of_day When true, uses 23:59:59 instead of 00:00:00
+ *
+ * @return int|false Timestamp on success, false if the string is empty or invalid
+ */
+ public function parse_date_filter($date_str, $end_of_day = false)
+ {
+ if ($date_str === '')
+ {
+ return false;
+ }
+
+ $dt = \DateTimeImmutable::createFromFormat('!Y-m-d', $date_str, new \DateTimeZone('UTC'));
+
+ if ($dt === false || $dt->format('Y-m-d') !== $date_str)
+ {
+ return false;
+ }
+
+ return $end_of_day
+ ? (int) $dt->setTime(23, 59, 59)->getTimestamp()
+ : (int) $dt->getTimestamp();
+ }
+
+ /**
+ * Write filtered consent log rows as CSV to the given file handle.
+ *
+ * Uses keyset pagination on consent_log_id to iterate rows in batches,
+ * avoiding memory exhaustion on large datasets.
+ *
+ * @param resource $handle Writable stream (e.g. opened on php://output)
+ * @param array $filters Optional: date_from, date_to, user_id, consent_version
+ * @param int $batch_size Rows per DB query
+ *
+ * @return void
+ */
+ public function stream_logs_csv($handle, array $filters = [], $batch_size = 500)
+ {
+ $last_id = 0;
+
+ do
+ {
+ $sql = 'SELECT consent_log_id, anonymized_id, consent_time, consent_version, accepted_categories'
+ . ' FROM ' . $this->consent_logs_table
+ . $this->build_filter_where($filters, $last_id)
+ . ' ORDER BY consent_log_id ASC';
+
+ $result = $this->db->sql_query_limit($sql, $batch_size);
+ $count = 0;
+
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $count++;
+ $last_id = (int) $row['consent_log_id'];
+ $categories = json_decode($row['accepted_categories'], true);
+ $cat_string = is_array($categories) ? implode(',', $categories) : '';
+
+ fputcsv($handle, [
+ $row['anonymized_id'],
+ gmdate('Y-m-d\TH:i:s\Z', (int) $row['consent_time']),
+ (int) $row['consent_version'],
+ $this->sanitize_csv_value($cat_string),
+ ]);
+ }
+
+ $this->db->sql_freeresult($result);
+ }
+ while ($count === $batch_size);
+ }
+
+ /**
+ * Compute the anonymized identifier for a given registered user ID.
+ *
+ * Mirrors the HMAC used in log_manager::log_consent() so that admins can
+ * filter exports by user ID without exposing raw identifiers.
+ *
+ * Note: only matches rows hashed with the current config[rand_seed]. Records
+ * logged before a rand_seed rotation will not be found.
+ *
+ * @param int $user_id Numeric phpBB user ID (must be > 0)
+ *
+ * @return string 64-character hex hash
+ */
+ public function hash_user_id($user_id)
+ {
+ return hash_hmac('sha256', 'u:' . (int) $user_id, $this->config['rand_seed']);
+ }
+
+ /**
+ * Add an admin log entry for consent settings changes.
+ *
+ * @return void
+ */
+ public function log_admin_settings_updated()
+ {
+ $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONSENTMANAGER_UPDATED');
+ }
+
+ /**
+ * Add an admin log entry when users are re-prompted for consent.
+ *
+ * @return void
+ */
+ public function log_admin_reprompt()
+ {
+ $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONSENTMANAGER_REPROMPT');
+ }
+
+ /**
+ * Add an admin log entry when consent logs are exported.
+ *
+ * @return void
+ */
+ public function log_admin_export()
+ {
+ $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONSENTMANAGER_EXPORT');
+ }
+
+ /**
+ * Build a WHERE clause for consent log queries.
+ *
+ * The keyset condition (consent_log_id > last_id) is always included so
+ * that the caller can page through results without OFFSET.
+ *
+ * @param array $filters Filter map from parse_export_filters
+ * @param int $last_id Highest consent_log_id seen in the previous batch
+ *
+ * @return string SQL WHERE clause (including the leading " WHERE " keyword)
+ */
+ protected function build_filter_where(array $filters, $last_id = 0)
+ {
+ $where = ['consent_log_id > ' . (int) $last_id];
+
+ if (!empty($filters['date_from']))
+ {
+ $where[] = 'consent_time >= ' . (int) $filters['date_from'];
+ }
+
+ if (!empty($filters['date_to']))
+ {
+ $where[] = 'consent_time <= ' . (int) $filters['date_to'];
+ }
+
+ if (!empty($filters['user_id']))
+ {
+ $anonymized = $this->hash_user_id((int) $filters['user_id']);
+ $where[] = "anonymized_id = '" . $this->db->sql_escape($anonymized) . "'";
+ }
+
+ if (!empty($filters['consent_version']))
+ {
+ $where[] = 'consent_version = ' . (int) $filters['consent_version'];
+ }
+
+ return ' WHERE ' . implode(' AND ', $where);
+ }
+
+ protected function sanitize_csv_value($value)
+ {
+ // Prevent spreadsheet formula injection (CSV injection).
+ // Excel/LibreOffice treat cells starting with =, +, -, @, or \t as formulas.
+ if ($value !== '' && strpos('=+-@' . "\t", $value[0]) !== false)
+ {
+ return "\t" . $value;
+ }
+
+ return $value;
+ }
+}
diff --git a/service/log_manager.php b/service/log_manager.php
index e8481f6..b0033c7 100644
--- a/service/log_manager.php
+++ b/service/log_manager.php
@@ -12,7 +12,6 @@
use phpbb\config\config;
use phpbb\db\driver\driver_interface;
-use phpbb\log\log as phpbb_log;
use phpbb\user;
class log_manager
@@ -23,9 +22,6 @@ class log_manager
/** @var driver_interface */
protected $db;
- /** @var phpbb_log */
- protected $log;
-
/** @var user */
protected $user;
@@ -37,15 +33,13 @@ class log_manager
*
* @param config $config Config service
* @param driver_interface $db Database connection
- * @param phpbb_log $log phpBB log service
* @param user $user Current user
* @param string $consent_logs_table Consent log table name
*/
- public function __construct(config $config, driver_interface $db, phpbb_log $log, user $user, $consent_logs_table)
+ public function __construct(config $config, driver_interface $db, user $user, $consent_logs_table)
{
$this->config = $config;
$this->db = $db;
- $this->log = $log;
$this->user = $user;
$this->consent_logs_table = $consent_logs_table;
}
@@ -71,26 +65,6 @@ public function log_consent(array $categories, $version)
$this->db->sql_query($sql);
}
- /**
- * Add an admin log entry for consent settings changes.
- *
- * @return void
- */
- public function log_admin_settings_updated()
- {
- $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONSENTMANAGER_UPDATED');
- }
-
- /**
- * Add an admin log entry when users are re-prompted for consent.
- *
- * @return void
- */
- public function log_admin_reprompt()
- {
- $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONSENTMANAGER_REPROMPT');
- }
-
/**
* Build an anonymized identifier for the current user or session.
*
diff --git a/tests/acp/acp_module_test.php b/tests/acp/acp_module_test.php
index a2d2902..5e4610e 100644
--- a/tests/acp/acp_module_test.php
+++ b/tests/acp/acp_module_test.php
@@ -20,9 +20,21 @@ class acp_module_test extends \phpbb_test_case
/** @var \phpbb\module\module_manager */
protected $module_manager;
+ /** @var \phpbb\request\request|\PHPUnit\Framework\MockObject\MockObject */
+ protected $request;
+
+ /** @var \phpbb\template\template|\PHPUnit\Framework\MockObject\MockObject */
+ protected $template;
+
+ /** @var \phpbb\consentmanager\controller\acp_controller|\PHPUnit\Framework\MockObject\MockObject */
+ protected $acp_controller;
+
+ /** @var \Symfony\Component\DependencyInjection\ContainerInterface|\PHPUnit\Framework\MockObject\MockObject */
+ protected $container;
+
protected function setUp(): void
{
- global $phpbb_dispatcher, $phpbb_extension_manager, $phpbb_root_path, $phpEx;
+ global $phpbb_dispatcher, $phpbb_extension_manager, $phpbb_root_path, $phpEx, $phpbb_container, $request, $template;
$this->extension_manager = new \phpbb_mock_extension_manager(
$phpbb_root_path,
@@ -37,7 +49,7 @@ protected function setUp(): void
$this->module_manager = new \phpbb\module\module_manager(
new \phpbb\cache\driver\dummy(),
- $this->getMockBuilder('\phpbb\db\driver\driver_interface')->getMock(),
+ $this->createMock('\phpbb\db\driver\driver_interface'),
$this->extension_manager,
MODULES_TABLE,
$phpbb_root_path,
@@ -45,6 +57,20 @@ protected function setUp(): void
);
$phpbb_dispatcher = new \phpbb_mock_event_dispatcher();
+
+ if (!defined('IN_ADMIN'))
+ {
+ define('IN_ADMIN', true);
+ }
+
+ $this->request = $this->createMock('\phpbb\request\request');
+ $this->template = $this->createMock('\phpbb\template\template');
+ $this->acp_controller = $this->createMock('\phpbb\consentmanager\controller\acp_controller');
+ $this->container = $this->createMock('Symfony\Component\DependencyInjection\ContainerInterface');
+
+ $phpbb_container = $this->container;
+ $request = $this->request;
+ $template = $this->template;
}
public function test_module_info()
@@ -59,6 +85,11 @@ public function test_module_info()
'auth' => 'ext_phpbb/consentmanager && acl_a_board',
'cat' => ['ACP_CONSENTMANAGER']
],
+ 'export' => [
+ 'title' => 'ACP_CONSENTMANAGER_EXPORT',
+ 'auth' => 'ext_phpbb/consentmanager && acl_a_board',
+ 'cat' => ['ACP_CONSENTMANAGER']
+ ],
],
],
], $this->module_manager->get_module_infos('acp', 'consentmanager_module'));
@@ -83,39 +114,42 @@ public function test_module_auth($module_auth, $expected)
public function test_main_module()
{
- global $phpbb_container, $request, $template;
+ $this->expect_controller_method('handle');
- if (!defined('IN_ADMIN'))
- {
- define('IN_ADMIN', true);
- }
+ $this->create_p_master()->load('acp', '\phpbb\consentmanager\acp\consentmanager_module');
+ }
+
+ public function test_main_module_export_mode()
+ {
+ $this->expect_controller_method('handle_export');
- $request = $this->getMockBuilder('\phpbb\request\request')
- ->disableOriginalConstructor()
- ->getMock();
- $template = $this->getMockBuilder('\phpbb\template\template')
- ->disableOriginalConstructor()
- ->getMock();
- $phpbb_container = $this->getMockBuilder('Symfony\Component\DependencyInjection\ContainerInterface')
- ->disableOriginalConstructor()
- ->getMock();
- $acp_controller = $this->getMockBuilder('\phpbb\consentmanager\controller\acp_controller')
- ->disableOriginalConstructor()
- ->getMock();
-
- $phpbb_container
+ $module = new \phpbb\consentmanager\acp\consentmanager_module();
+ $module->u_action = 'adm.php?i=test&mode=export';
+ $module->main('', 'export');
+
+ self::assertSame('consentmanager_acp_export', $module->tpl_name);
+ self::assertSame('ACP_CONSENTMANAGER_EXPORT', $module->page_title);
+ }
+
+ protected function expect_controller_method($method)
+ {
+ $this->container
->expects(self::once())
->method('get')
->with('phpbb.consentmanager.controller.acp')
- ->willReturn($acp_controller);
+ ->willReturn($this->acp_controller);
- $acp_controller
+ $this->acp_controller
->expects(self::once())
- ->method('handle');
+ ->method($method);
+ }
+ protected function create_p_master()
+ {
$p_master = new \p_master();
$p_master->module_ary[0]['is_duplicate'] = 0;
$p_master->module_ary[0]['url_extra'] = '';
- $p_master->load('acp', '\phpbb\consentmanager\acp\consentmanager_module');
+
+ return $p_master;
}
}
diff --git a/tests/controller/acp_controller_test.php b/tests/controller/acp_controller_test.php
index edf7b0e..ae10e31 100644
--- a/tests/controller/acp_controller_test.php
+++ b/tests/controller/acp_controller_test.php
@@ -23,6 +23,15 @@ class acp_controller_test extends \phpbb_test_case
/** @var \phpbb\user */
protected $user;
+ /** @var \phpbb\template\template|\PHPUnit\Framework\MockObject\MockObject */
+ protected $template;
+
+ /** @var \phpbb\consentmanager\service\consent_manager_interface|\PHPUnit\Framework\MockObject\MockObject */
+ protected $consent_manager;
+
+ /** @var \phpbb\consentmanager\service\acp_manager|\PHPUnit\Framework\MockObject\MockObject */
+ protected $acp_manager;
+
protected function setUp(): void
{
parent::setUp();
@@ -30,207 +39,292 @@ protected function setUp(): void
global $phpbb_root_path, $phpEx;
$lang_loader = new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx);
+ $lang_loader->set_extension_manager(new \phpbb_mock_extension_manager(
+ $phpbb_root_path,
+ [
+ 'phpbb/consentmanager' => [
+ 'ext_name' => 'phpbb/consentmanager',
+ 'ext_active' => '1',
+ 'ext_path' => 'ext/phpbb/consentmanager/',
+ ],
+ ]
+ ));
$this->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(
+ $this->user->data = [
'user_id' => 2,
'user_form_salt' => 'form-salt',
- );
+ ];
$this->user->session_id = 'session-id';
$this->user->lang = $this->language->get_lang_array();
+
+ $this->template = $this->createMock('\phpbb\template\template');
+ $this->consent_manager = $this->createMock('\phpbb\consentmanager\service\consent_manager_interface');
+ $this->acp_manager = $this->createMock('\phpbb\consentmanager\service\acp_manager');
+ }
+
+ protected function create_controller($request, $u_action = 'adm.php?i=test')
+ {
+ $controller = new \phpbb\consentmanager\controller\acp_controller(
+ $this->language,
+ $this->consent_manager,
+ $this->acp_manager,
+ $request,
+ $this->template
+ );
+ $controller->set_page_url($u_action);
+ return $controller;
}
public function test_handle_assigns_existing_template_data()
{
- $request = $this->create_request_mock();
- $template = $this->createMock('\phpbb\template\template');
- $template->expects(self::once())
+ $this->consent_manager->expects(self::once())
+ ->method('get_acp_template_data')
+ ->willReturn([
+ 'S_CONSENTMANAGER_ANALYTICS' => true,
+ 'CONSENTMANAGER_VERSION' => 1,
+ ]);
+ $this->template->expects(self::once())
->method('assign_vars')
- ->with(array(
+ ->with([
'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();
+ $this->create_controller($this->create_request_mock())->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())
+ $this->consent_manager->expects(self::once())
->method('save_acp_settings')
->willReturnCallback(function (array $settings, array &$errors) {
- self::assertSame(array(
+ self::assertSame([
'analytics_enabled' => 1,
'marketing_enabled' => 0,
'integrations' => 'invalid json',
- ), $settings);
-
- $errors = array('Invalid integrations');
+ ], $settings);
+ $errors = ['Invalid integrations'];
return false;
});
- $consent_manager->expects(self::once())
+ $this->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');
+ ->willReturn(['CONSENTMANAGER_VERSION' => 3]);
+ $this->acp_manager->expects(self::never())->method('log_admin_settings_updated');
+ $this->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']);
+ }));
- $controller = new \phpbb\consentmanager\controller\acp_controller(
- $this->language,
- $consent_manager,
- $log_manager,
- $request,
- $template
+ $request = $this->create_request_mock(
+ [
+ 'submit' => 1,
+ 'consentmanager_analytics_enabled' => 1,
+ 'consentmanager_marketing_enabled' => 0
+ ],
+ ['consentmanager_integrations' => " invalid json \n"]
);
- $controller->set_page_url('adm.php?i=test');
- $controller->handle();
+ $this->create_controller($request)->handle();
}
public function test_handle_submit_success_logs_and_triggers_success_notice()
{
self::$valid_form = true;
+ $this->consent_manager->expects(self::once())->method('save_acp_settings')->willReturn(true);
+ $this->acp_manager->expects(self::once())->method('log_admin_settings_updated');
+ $this->setExpectedTriggerError(E_USER_NOTICE, $this->language->lang('CONFIG_UPDATED'));
+
$request = $this->create_request_mock(
- array(
+ [
'submit' => 1,
'consentmanager_analytics_enabled' => 0,
- 'consentmanager_marketing_enabled' => 1,
- ),
- array(
- 'consentmanager_integrations' => '[]',
- )
+ 'consentmanager_marketing_enabled' => 1
+ ],
+ ['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();
+ $this->create_controller($request)->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->consent_manager->expects(self::once())->method('reset_consent_version');
+ $this->acp_manager->expects(self::once())->method('log_admin_reprompt');
$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');
+ $this->create_controller($this->create_request_mock(['reset_consent' => 1]))->handle();
+ }
- $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager');
- $log_manager->expects(self::once())
- ->method('log_admin_reprompt');
+ public function test_handle_rejects_invalid_form_key()
+ {
+ self::$valid_form = false;
- $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();
+ $this->consent_manager->expects(self::never())->method('save_acp_settings');
+ $this->setExpectedTriggerError(E_USER_WARNING, $this->language->lang('FORM_INVALID'));
+
+ $this->create_controller($this->create_request_mock(['submit' => 1]))->handle();
}
- public function test_handle_rejects_invalid_form_key()
+ public function test_handle_reset_consent_rejects_invalid_form_key()
{
self::$valid_form = false;
- $request = $this->create_request_mock(array(
- 'submit' => 1,
- ));
- $template = $this->createMock('\phpbb\template\template');
+ $this->consent_manager->expects(self::never())->method('reset_consent_version');
+ $this->acp_manager->expects(self::never())->method('log_admin_reprompt');
$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');
+ $this->create_controller($this->create_request_mock(['reset_consent' => 1]))->handle();
+ }
- $log_manager = $this->createMock('\phpbb\consentmanager\service\log_manager');
+ public function test_handle_export_shows_empty_form()
+ {
+ $this->template->expects(self::once())
+ ->method('assign_vars')
+ ->with([
+ 'S_ERROR' => false,
+ 'ERROR_MSG' => '',
+ 'EXPORT_DATE_FROM' => '',
+ 'EXPORT_DATE_TO' => '',
+ 'EXPORT_USER_ID' => 0,
+ 'EXPORT_CONSENT_VER' => 0,
+ 'U_ACTION' => 'adm.php?i=test&mode=export',
+ ]);
+
+ $this->create_controller($this->create_request_mock(), 'adm.php?i=test&mode=export')->handle_export();
+ }
- $controller = new \phpbb\consentmanager\controller\acp_controller(
+ public function test_handle_export_rejects_invalid_form_key()
+ {
+ self::$valid_form = false;
+
+ $this->setExpectedTriggerError(E_USER_WARNING, $this->language->lang('FORM_INVALID'));
+
+ $this->create_controller($this->create_request_mock(['download_csv' => 1]), 'adm.php?i=test&mode=export')->handle_export();
+ }
+
+ public function test_handle_export_invalid_date_from_shows_error()
+ {
+ self::$valid_form = true;
+
+ $this->acp_manager->method('parse_date_filter')->willReturn(false);
+ $this->acp_manager->expects(self::never())->method('stream_logs_csv');
+ $this->acp_manager->expects(self::never())->method('log_admin_export');
+ $this->template->expects(self::once())
+ ->method('assign_vars')
+ ->with(self::callback(function ($vars) {
+ return $vars['S_ERROR'] === true && strpos($vars['ERROR_MSG'], 'Date from') !== false;
+ }));
+
+ $request = $this->create_request_mock([
+ 'download_csv' => 1,
+ 'export_date_from' => 'not-a-date',
+ 'export_date_to' => '',
+ 'export_user_id' => 0,
+ 'export_consent_version' => 0,
+ ]);
+ $this->create_controller($request, 'adm.php?i=test&mode=export')->handle_export();
+ }
+
+ public function test_handle_export_invalid_date_to_shows_error()
+ {
+ self::$valid_form = true;
+
+ $this->acp_manager->method('parse_date_filter')->willReturn(false);
+ $this->acp_manager->expects(self::never())->method('stream_logs_csv');
+ $this->acp_manager->expects(self::never())->method('log_admin_export');
+ $this->template->expects(self::once())
+ ->method('assign_vars')
+ ->with(self::callback(function ($vars) {
+ return $vars['S_ERROR'] === true && strpos($vars['ERROR_MSG'], 'Date to') !== false;
+ }));
+
+ $request = $this->create_request_mock([
+ 'download_csv' => 1,
+ 'export_date_from' => '',
+ 'export_date_to' => '2024-13-01',
+ 'export_user_id' => 0,
+ 'export_consent_version' => 0,
+ ]);
+ $this->create_controller($request, 'adm.php?i=test&mode=export')->handle_export();
+ }
+
+ public function test_handle_export_reversed_date_range_shows_error()
+ {
+ self::$valid_form = true;
+
+ $this->acp_manager->method('parse_date_filter')
+ ->willReturnOnConsecutiveCalls(1735603200, 1704067200);
+ $this->acp_manager->expects(self::never())->method('stream_logs_csv');
+ $this->acp_manager->expects(self::never())->method('log_admin_export');
+ $this->template->expects(self::once())
+ ->method('assign_vars')
+ ->with(self::callback(function ($vars) {
+ return $vars['S_ERROR'] === true && strpos($vars['ERROR_MSG'], '"Date from"') !== false;
+ }));
+
+ $request = $this->create_request_mock([
+ 'download_csv' => 1,
+ 'export_date_from' => '2024-12-31',
+ 'export_date_to' => '2024-01-01',
+ 'export_user_id' => 0,
+ 'export_consent_version' => 0,
+ ]);
+ $this->create_controller($request, 'adm.php?i=test&mode=export')->handle_export();
+ }
+
+ public function test_handle_export_success_logs_and_passes_filters_to_download()
+ {
+ self::$valid_form = true;
+
+ $this->acp_manager->method('parse_date_filter')
+ ->willReturnOnConsecutiveCalls(1704067200, 1735689599);
+ $this->acp_manager->expects(self::once())->method('log_admin_export');
+
+ $request = $this->create_request_mock([
+ 'download_csv' => 1,
+ 'export_date_from' => '2024-01-01',
+ 'export_date_to' => '2024-12-31',
+ 'export_user_id' => 42,
+ 'export_consent_version' => 2,
+ ]);
+
+ $controller = new \phpbb\consentmanager\tests\controller\testable_acp_controller(
$this->language,
- $consent_manager,
- $log_manager,
+ $this->consent_manager,
+ $this->acp_manager,
$request,
- $template
+ $this->template
);
- $controller->set_page_url('adm.php?i=test');
- $controller->handle();
+ $controller->set_page_url('adm.php?i=test&mode=export');
+ $controller->handle_export();
+
+ self::assertSame([
+ 'date_from' => 1704067200,
+ 'date_to' => 1735689599,
+ 'user_id' => 42,
+ 'consent_version' => 2,
+ ], $controller->captured_filters);
}
- protected function create_request_mock(array $values = array(), array $raw_values = array())
+ protected function create_request_mock(array $values = [], array $raw_values = [])
{
$request = $this->getMockBuilder('\phpbb\request\request')
->disableOriginalConstructor()
- ->setMethods(array('is_set_post', 'variable', 'raw_variable'))
+ ->setMethods(['is_set_post', 'variable', 'raw_variable'])
->getMock();
$request->method('is_set_post')
@@ -272,6 +366,20 @@ protected function create_request_mock(array $values = array(), array $raw_value
}
}
+namespace phpbb\consentmanager\tests\controller;
+
+class testable_acp_controller extends \phpbb\consentmanager\controller\acp_controller
+{
+ /** @var array|null Filters captured from the last send_csv_download call */
+ public $captured_filters;
+
+ protected function send_csv_download(array $filters)
+ {
+ $this->captured_filters = $filters;
+ // Do not stream or exit — just record the filters for assertions
+ }
+}
+
namespace phpbb\consentmanager\controller;
function add_form_key()
diff --git a/tests/service/acp_manager_test.php b/tests/service/acp_manager_test.php
new file mode 100644
index 0000000..1b1b4eb
--- /dev/null
+++ b/tests/service/acp_manager_test.php
@@ -0,0 +1,347 @@
+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_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();
+ }
+
+ public function test_log_admin_export_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_EXPORT');
+
+ $manager = $this->create_manager(7, 'admin-session', $log);
+ $manager->log_admin_export();
+ }
+
+ public function test_hash_user_id_returns_hmac_of_user_prefix()
+ {
+ $manager = $this->create_manager(1, 'session');
+ $expected = hash_hmac('sha256', 'u:42', 'random-seed');
+
+ self::assertSame($expected, $manager->hash_user_id(42));
+ }
+
+ public function test_hash_user_id_is_consistent()
+ {
+ $manager = $this->create_manager(1, 'session');
+
+ self::assertSame($manager->hash_user_id(99), $manager->hash_user_id(99));
+ self::assertNotSame($manager->hash_user_id(1), $manager->hash_user_id(2));
+ }
+
+ public function test_stream_logs_csv_empty_table_writes_no_rows()
+ {
+ $manager = $this->create_manager(1, 'session');
+
+ $handle = fopen('php://memory', 'w+');
+ $manager->stream_logs_csv($handle, []);
+ rewind($handle);
+ $content = stream_get_contents($handle);
+ fclose($handle);
+
+ self::assertSame('', $content);
+ }
+
+ public function test_stream_logs_csv_writes_all_rows_unfiltered()
+ {
+ $log_manager_a = $this->create_log_manager(10, 'session-a');
+ $log_manager_a->log_consent(array('necessary', 'analytics'), 2);
+
+ $log_manager_b = $this->create_log_manager(20, 'session-b');
+ $log_manager_b->log_consent(array('necessary'), 2);
+
+ $handle = fopen('php://memory', 'w+');
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, []);
+ rewind($handle);
+ $rows = array_filter(explode("\n", stream_get_contents($handle)));
+ fclose($handle);
+
+ self::assertCount(2, $rows);
+ }
+
+ public function test_stream_logs_csv_filters_by_consent_version()
+ {
+ $log_manager = $this->create_log_manager(10, 'session');
+ $log_manager->log_consent(array('necessary'), 1);
+ $log_manager->log_consent(array('necessary', 'analytics'), 2);
+ $log_manager->log_consent(array('necessary'), 1);
+
+ $handle = fopen('php://memory', 'w+');
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, array('consent_version' => 1));
+ rewind($handle);
+ $rows = array_filter(explode("\n", stream_get_contents($handle)));
+ fclose($handle);
+
+ self::assertCount(2, $rows);
+ foreach ($rows as $row)
+ {
+ self::assertStringContainsString(',1,', $row);
+ }
+ }
+
+ public function test_stream_logs_csv_filters_by_date_range()
+ {
+ $db = $this->new_dbal();
+ $now = time();
+ $past = $now - 7200; // 2 hours ago
+
+ $db->sql_query('INSERT INTO phpbb_consentmanager_logs
+ (anonymized_id, consent_version, accepted_categories, consent_time)
+ VALUES
+ (\'' . $db->sql_escape('hash-old') . '\', 1, \'["necessary"]\', ' . $past . '),
+ (\'' . $db->sql_escape('hash-new') . '\', 1, \'["necessary","analytics"]\', ' . $now . ')');
+ $db->sql_close();
+
+ $handle = fopen('php://memory', 'w+');
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, array(
+ 'date_from' => $now - 3600, // 1 hour ago
+ 'date_to' => $now + 3600,
+ ));
+ rewind($handle);
+ $rows = array_filter(explode("\n", stream_get_contents($handle)));
+ fclose($handle);
+
+ self::assertCount(1, $rows);
+ self::assertStringContainsString('hash-new', reset($rows));
+ }
+
+ public function test_stream_logs_csv_filters_by_user_id()
+ {
+ $manager_target = $this->create_log_manager(42, 'any-session');
+ $manager_target->log_consent(array('necessary'), 1);
+
+ $manager_other = $this->create_log_manager(99, 'other-session');
+ $manager_other->log_consent(array('necessary', 'analytics'), 1);
+
+ $reader = $this->create_manager(1, 'session');
+
+ $handle = fopen('php://memory', 'w+');
+ $reader->stream_logs_csv($handle, array('user_id' => 42));
+ rewind($handle);
+ $rows = array_filter(explode("\n", stream_get_contents($handle)));
+ fclose($handle);
+
+ self::assertCount(1, $rows);
+
+ $expected_hash = hash_hmac('sha256', 'u:42', 'random-seed');
+ self::assertStringContainsString($expected_hash, reset($rows));
+ }
+
+ public function test_stream_logs_csv_row_format_is_correct()
+ {
+ $log_manager = $this->create_log_manager(5, 'session');
+ $log_manager->log_consent(array('necessary', 'analytics'), 3);
+
+ $handle = fopen('php://memory', 'w+');
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, []);
+ rewind($handle);
+ $content = stream_get_contents($handle);
+ fclose($handle);
+
+ $row = str_getcsv(trim($content));
+ self::assertCount(4, $row);
+
+ // anonymized_id: 64-char hex
+ self::assertRegExp('/^[0-9a-f]{64}$/', $row[0]);
+
+ // timestamp: ISO 8601 UTC
+ self::assertRegExp('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $row[1]);
+
+ // consent_version
+ self::assertSame('3', $row[2]);
+
+ // categories as comma-separated string
+ self::assertSame('necessary,analytics', $row[3]);
+ }
+
+ public function test_stream_logs_csv_batch_pagination_retrieves_all_rows()
+ {
+ $log_manager = $this->create_log_manager(1, 'session');
+
+ for ($i = 0; $i < 5; $i++)
+ {
+ $log_manager->log_consent(array('necessary'), 1);
+ }
+
+ $handle = fopen('php://memory', 'w+');
+ // Use a batch size of 2 to exercise the pagination loop
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, [], 2);
+ rewind($handle);
+ $rows = array_filter(explode("\n", stream_get_contents($handle)));
+ fclose($handle);
+
+ self::assertCount(5, $rows);
+ }
+
+ public function test_stream_logs_csv_sanitizes_formula_injection_in_categories()
+ {
+ $db = $this->new_dbal();
+ // Insert a row whose accepted_categories begins with '=' — a formula injection attempt
+ $db->sql_query('INSERT INTO phpbb_consentmanager_logs
+ (anonymized_id, consent_version, accepted_categories, consent_time)
+ VALUES (\'hash-x\', 1, \'["=DANGEROUS()"]\', ' . time() . ')');
+ $db->sql_close();
+
+ $handle = fopen('php://memory', 'w+');
+ $this->create_manager(1, 'session')->stream_logs_csv($handle, []);
+ rewind($handle);
+ $row = str_getcsv(trim(stream_get_contents($handle)));
+ fclose($handle);
+
+ // categories cell must be prefixed with a tab to defuse the formula
+ self::assertStringStartsWith("\t", $row[3]);
+ self::assertStringContainsString('=DANGEROUS()', $row[3]);
+ }
+
+ public function test_parse_date_filter_returns_false_for_empty_string()
+ {
+ $manager = $this->create_manager(1, 'session');
+ self::assertFalse($manager->parse_date_filter(''));
+ }
+
+ public function test_parse_date_filter_returns_false_for_invalid_date()
+ {
+ $manager = $this->create_manager(1, 'session');
+ self::assertFalse($manager->parse_date_filter('not-a-date'));
+ self::assertFalse($manager->parse_date_filter('2024-13-01'));
+ self::assertFalse($manager->parse_date_filter('2024-02-31'));
+ }
+
+ public function test_parse_date_filter_returns_start_of_day_timestamp()
+ {
+ $manager = $this->create_manager(1, 'session');
+ $ts = $manager->parse_date_filter('2024-06-15');
+ self::assertSame(
+ \DateTimeImmutable::createFromFormat('!Y-m-d', '2024-06-15', new \DateTimeZone('UTC'))->getTimestamp(),
+ $ts
+ );
+ }
+
+ public function test_parse_date_filter_returns_end_of_day_timestamp_when_flag_set()
+ {
+ $manager = $this->create_manager(1, 'session');
+ $start = $manager->parse_date_filter('2024-06-15');
+ $end = $manager->parse_date_filter('2024-06-15', true);
+ self::assertSame(86399, $end - $start); // 23h 59m 59s difference
+ }
+
+ 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\acp_manager(
+ $config,
+ $db,
+ $log,
+ $user,
+ 'phpbb_consentmanager_logs'
+ );
+ }
+
+ protected function create_log_manager($user_id, $session_id)
+ {
+ $config = new \phpbb\config\config(array(
+ 'rand_seed' => 'random-seed',
+ ));
+ $db = $this->new_dbal();
+
+ $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,
+ $user,
+ 'phpbb_consentmanager_logs'
+ );
+ }
+}
diff --git a/tests/service/log_manager_test.php b/tests/service/log_manager_test.php
index 0723fb2..a0762da 100644
--- a/tests/service/log_manager_test.php
+++ b/tests/service/log_manager_test.php
@@ -69,48 +69,13 @@ public function test_log_consent_uses_session_identifier_for_guests()
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)
+ protected function create_manager($user_id, $session_id)
{
$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,
@@ -121,7 +86,6 @@ protected function create_manager($user_id, $session_id, $log = null)
return new \phpbb\consentmanager\service\log_manager(
$config,
$db,
- $log,
$user,
'phpbb_consentmanager_logs'
);