From eb5bf78225edc5761a654c51e477e436c5dc3c7c Mon Sep 17 00:00:00 2001 From: Michael Gasperini <163041857+TheStreamCode@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:06 +0200 Subject: [PATCH 01/13] Add faithful WordPress shims and behavioral test coverage for admin, views, REST wiring and i18n runtime - tests/bootstrap.php: faithful esc_* / form / redirect / locale shims, filter-aware apply_filters, action-recording nonce shims, REST route registration capture, view-capable globals - New PDMViewsEscapingTest, PDMNonceRejectionTest, PDMRestRouteRegistrationTest, PDMI18nRuntimeTest, PDMAdminHandlersTest cover the previously untested view layer, rejection paths, REST permission wiring, runtime gettext fallback and admin handler guards - Replace the source-text assertion for the interface-language allowlist with behavioral coverage Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- tests/PDMAdminHandlersTest.php | 187 ++++++++++++++++++++++++ tests/PDMAdminSecurityNoticeTest.php | 7 - tests/PDMI18nRuntimeTest.php | 71 +++++++++ tests/PDMNonceRejectionTest.php | 89 +++++++++++ tests/PDMRestRouteRegistrationTest.php | 195 +++++++++++++++++++++++++ tests/PDMViewsEscapingTest.php | 150 +++++++++++++++++++ tests/bootstrap.php | 188 +++++++++++++++++++++++- 7 files changed, 877 insertions(+), 10 deletions(-) create mode 100644 tests/PDMAdminHandlersTest.php create mode 100644 tests/PDMI18nRuntimeTest.php create mode 100644 tests/PDMNonceRejectionTest.php create mode 100644 tests/PDMRestRouteRegistrationTest.php create mode 100644 tests/PDMViewsEscapingTest.php diff --git a/tests/PDMAdminHandlersTest.php b/tests/PDMAdminHandlersTest.php new file mode 100644 index 0000000..18552fa --- /dev/null +++ b/tests/PDMAdminHandlersTest.php @@ -0,0 +1,187 @@ + false, + 'mstv_allowed_users' => [], + ]; + $GLOBALS['pdm_test_current_user_caps'] = []; + $GLOBALS['pdm_test_current_user_can'] = false; + $GLOBALS['pdm_test_is_user_logged_in'] = true; + $GLOBALS['pdm_test_current_user_id'] = 5; + $GLOBALS['pdm_test_transients'] = []; + $GLOBALS['pdm_test_verified_nonce_actions'] = []; + $GLOBALS['pdm_test_redirects'] = []; + $_POST = []; + $_GET = []; + $_REQUEST = []; + } + + protected function tearDown(): void + { + $GLOBALS['pdm_test_current_user_caps'] = []; + $GLOBALS['pdm_test_is_user_logged_in'] = false; + $GLOBALS['pdm_test_current_user_id'] = 0; + $GLOBALS['pdm_test_transients'] = []; + $GLOBALS['pdm_test_verified_nonce_actions'] = []; + $GLOBALS['pdm_test_redirects'] = []; + $_POST = []; + $_GET = []; + $_REQUEST = []; + } + + private function admin(): MSTV_Admin + { + return new MSTV_Admin(new MSTV_Settings()); + } + + public function test_render_callbacks_die_without_capability(): void + { + $admin = $this->admin(); + + $callbacks = [ + [$admin, 'render_file_manager_page'], + [$admin, 'render_settings_page'], + [$admin, 'render_groups_page'], + [$admin, 'render_quotas_page'], + [$admin, 'render_notifications_page'], + [$admin, 'render_reports_page'], + [$admin, 'render_logs_page'], + ]; + + foreach ($callbacks as $callback) { + try { + $callback(); + self::fail('A render callback ran without the required capability.'); + } catch (RuntimeException $exception) { + self::assertSame( + 'You do not have permission to access this page.', + $exception->getMessage() + ); + } + } + } + + public function test_file_manager_page_renders_for_authorized_users(): void + { + $GLOBALS['pdm_test_current_user_caps'] = [ + MSTV_Capabilities::CAP_MANAGE => true, + ]; + + ob_start(); + $this->admin()->render_file_manager_page(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString('pdm-app', $html); + self::assertStringContainsString('pdm-folder-tree', $html); + } + + public function test_handle_save_settings_rejects_unsupported_interface_language(): void + { + $GLOBALS['pdm_test_current_user_caps'] = ['manage_options' => true]; + $_POST = [ + 'mstv_settings_nonce' => 'valid-nonce', + 'mstv_interface_language' => 'xx', + ]; + + try { + $this->admin()->handle_save_settings(); + self::fail('handle_save_settings did not redirect after saving.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString('redirect:', $exception->getMessage()); + } + + self::assertSame('en', get_option('mstv_interface_language', 'auto')); + self::assertContains('mstv_settings_nonce', $GLOBALS['pdm_test_verified_nonce_actions']); + } + + public function test_handle_save_settings_stores_sanitized_settings(): void + { + $GLOBALS['pdm_test_current_user_caps'] = ['manage_options' => true]; + $_POST = [ + 'mstv_settings_nonce' => 'valid-nonce', + 'mstv_interface_language' => 'it', + 'mstv_allowed_extensions' => 'pdf, doc', + 'mstv_max_file_size' => '1024', + ]; + + try { + $this->admin()->handle_save_settings(); + self::fail('handle_save_settings did not redirect after saving.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString('redirect:', $exception->getMessage()); + } + + self::assertSame('it', get_option('mstv_interface_language', 'auto')); + self::assertSame('pdf,doc', (string) get_option('mstv_allowed_extensions', '')); + self::assertSame(1024, (int) get_option('mstv_max_file_size', 0)); + self::assertTrue((bool) get_transient('mstv_settings_saved_5')); + } + + public function test_handle_save_settings_prevents_whitelist_lockout(): void + { + $GLOBALS['pdm_test_current_user_caps'] = ['manage_options' => true]; + $_POST = [ + 'mstv_settings_nonce' => 'valid-nonce', + 'mstv_use_user_whitelist' => '1', + 'mstv_allowed_users' => ['99'], + ]; + + try { + $this->admin()->handle_save_settings(); + self::fail('handle_save_settings allowed a whitelist that excludes the current user.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString('redirect:', $exception->getMessage()); + } + + $error = get_transient('mstv_settings_error_5'); + + self::assertIsString($error); + self::assertStringContainsString('Add your current account to the whitelist', $error); + self::assertFalse((bool) get_option('mstv_use_user_whitelist', false)); + } + + public function test_handle_export_audit_csv_dies_for_invalid_nonce(): void + { + $GLOBALS['pdm_test_current_user_caps'] = ['manage_options' => true]; + $_GET = ['mstv_audit_csv_nonce' => 'forged-nonce']; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid security token.'); + + $this->admin()->handle_export_audit_csv(); + } + + public function test_handle_download_file_dies_without_capability(): void + { + $_REQUEST = [ + 'file_id' => '1', + 'mstv_stream_nonce' => 'valid-nonce', + ]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('You do not have permission to access this page.'); + + $this->admin()->handle_download_file(); + } + + public function test_handle_download_file_dies_for_missing_nonce(): void + { + $GLOBALS['pdm_test_current_user_caps'] = [ + MSTV_Capabilities::CAP_MANAGE => true, + ]; + $_REQUEST = ['file_id' => '1']; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Are you sure you want to do this?'); + + $this->admin()->handle_download_file(); + } +} diff --git a/tests/PDMAdminSecurityNoticeTest.php b/tests/PDMAdminSecurityNoticeTest.php index 04f0d1c..d2c93cd 100644 --- a/tests/PDMAdminSecurityNoticeTest.php +++ b/tests/PDMAdminSecurityNoticeTest.php @@ -71,11 +71,4 @@ public function test_storage_security_notice_is_suppressed_after_dismiss(): void self::assertSame('', $notice); } - - public function test_settings_handler_uses_the_interface_language_allowlist(): void - { - $source = (string) file_get_contents(__DIR__ . '/../includes/class-mstv-admin.php'); - - self::assertStringContainsString('MSTV_I18n::sanitize_language(', $source); - } } diff --git a/tests/PDMI18nRuntimeTest.php b/tests/PDMI18nRuntimeTest.php new file mode 100644 index 0000000..06e22e6 --- /dev/null +++ b/tests/PDMI18nRuntimeTest.php @@ -0,0 +1,71 @@ + 'it', + ]; + $GLOBALS['pdm_test_actions'] = []; + } + + protected function tearDown(): void + { + $GLOBALS['pdm_test_options'] = []; + $GLOBALS['pdm_test_actions'] = []; + } + + public function test_gettext_filter_maps_string_for_configured_language(): void + { + $i18n = new MSTV_I18n(); + $i18n->init(); + + self::assertSame( + 'Notifiche', + apply_filters('gettext', 'Notifications', 'Notifications', 'mikesoft-teamvault') + ); + } + + public function test_gettext_filter_leaves_english_unchanged(): void + { + $GLOBALS['pdm_test_options']['mstv_interface_language'] = 'en'; + + $i18n = new MSTV_I18n(); + $i18n->init(); + + self::assertSame( + 'Notifications', + apply_filters('gettext', 'Notifications', 'Notifications', 'mikesoft-teamvault') + ); + } + + public function test_gettext_filter_ignores_other_domains(): void + { + $i18n = new MSTV_I18n(); + $i18n->init(); + + self::assertSame( + 'Notifications', + apply_filters('gettext', 'Notifications', 'Notifications', 'other-domain') + ); + } + + public function test_auto_language_falls_back_to_english_for_unsupported_locale(): void + { + $GLOBALS['pdm_test_options']['mstv_interface_language'] = 'auto'; + + self::assertSame('en', MSTV_I18n::get_language()); + } + + public function test_sanitize_language_rejects_unsupported_values(): void + { + self::assertSame('auto', MSTV_I18n::sanitize_language('auto')); + self::assertSame('it', MSTV_I18n::sanitize_language('it')); + self::assertSame('en', MSTV_I18n::sanitize_language('xx')); + } +} diff --git a/tests/PDMNonceRejectionTest.php b/tests/PDMNonceRejectionTest.php new file mode 100644 index 0000000..58cb573 --- /dev/null +++ b/tests/PDMNonceRejectionTest.php @@ -0,0 +1,89 @@ + false, + 'mstv_allowed_users' => [], + ]; + $GLOBALS['pdm_test_current_user_caps'] = [ + MSTV_Capabilities::CAP_MANAGE => true, + 'manage_options' => true, + ]; + $GLOBALS['pdm_test_is_user_logged_in'] = true; + $GLOBALS['pdm_test_current_user_id'] = 5; + $GLOBALS['pdm_test_verified_nonce_actions'] = []; + } + + protected function tearDown(): void + { + $GLOBALS['pdm_test_current_user_caps'] = []; + $GLOBALS['pdm_test_is_user_logged_in'] = false; + $GLOBALS['pdm_test_current_user_id'] = 0; + $GLOBALS['pdm_test_verified_nonce_actions'] = []; + } + + public function test_read_request_rejects_missing_nonce(): void + { + $auth = new MSTV_Auth(new MSTV_Settings()); + $request = new WP_REST_Request(); + + $result = $auth->verify_read_request($request); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('mstv_missing_nonce', $result->get_error_code()); + } + + public function test_read_request_rejects_invalid_nonce(): void + { + $auth = new MSTV_Auth(new MSTV_Settings()); + $request = new WP_REST_Request([], ['X-WP-Nonce' => 'forged-nonce']); + + $result = $auth->verify_read_request($request); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('mstv_invalid_nonce', $result->get_error_code()); + } + + public function test_write_delete_and_admin_guards_reject_missing_nonce(): void + { + $auth = new MSTV_Auth(new MSTV_Settings()); + $request = new WP_REST_Request(); + + foreach (['verify_write_request', 'verify_delete_request', 'verify_admin_request'] as $method) { + $result = $auth->{$method}($request); + + self::assertInstanceOf(WP_Error::class, $result, "Guard {$method} did not reject a missing nonce."); + self::assertSame('mstv_missing_nonce', $result->get_error_code()); + } + } + + public function test_unauthenticated_request_is_rejected_before_nonce_check(): void + { + $GLOBALS['pdm_test_is_user_logged_in'] = false; + + $auth = new MSTV_Auth(new MSTV_Settings()); + $request = new WP_REST_Request(); + + $result = $auth->verify_read_request($request); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('mstv_unauthorized', $result->get_error_code()); + self::assertSame([], $GLOBALS['pdm_test_verified_nonce_actions']); + } + + public function test_authorized_request_records_the_rest_nonce_action(): void + { + $auth = new MSTV_Auth(new MSTV_Settings()); + $request = new WP_REST_Request([], ['X-WP-Nonce' => 'valid-nonce']); + + self::assertTrue($auth->verify_read_request($request)); + self::assertSame(['wp_rest'], $GLOBALS['pdm_test_verified_nonce_actions']); + } +} diff --git a/tests/PDMRestRouteRegistrationTest.php b/tests/PDMRestRouteRegistrationTest.php new file mode 100644 index 0000000..0c329b9 --- /dev/null +++ b/tests/PDMRestRouteRegistrationTest.php @@ -0,0 +1,195 @@ + false, + 'mstv_allowed_users' => [], + ]; + } + + protected function tearDown(): void + { + $GLOBALS['pdm_test_rest_routes'] = []; + $GLOBALS['pdm_test_options'] = []; + } + + public function test_every_registered_route_has_a_permission_callback(): void + { + $map = $this->permission_map_for($this->register_file_routes() + $this->register_governance_routes()); + + self::assertNotEmpty($map); + + foreach ($map as $path => $byMethod) { + foreach ($byMethod as $method => $callback) { + self::assertIsArray( + $callback, + "Route {$path} [{$method}] has no permission callback." + ); + self::assertInstanceOf( + MSTV_Auth::class, + $callback[0], + "Route {$path} [{$method}] permission callback is not an MSTV_Auth method." + ); + self::assertIsString($callback[1]); + } + } + } + + public function test_file_routes_match_the_expected_permission_map(): void + { + $expected = [ + 'mstv/v1/browser' => ['GET' => 'verify_read_request'], + 'mstv/v1/folders' => ['POST' => 'verify_write_request'], + 'mstv/v1/folders/(?P\d+)' => [ + 'PATCH' => 'verify_write_request', + 'DELETE' => 'verify_delete_request', + ], + 'mstv/v1/folders/(?P\d+)/move' => ['POST' => 'verify_write_request'], + 'mstv/v1/files/upload' => ['POST' => 'verify_write_request'], + 'mstv/v1/files/(?P\d+)' => [ + 'PATCH' => 'verify_write_request', + 'DELETE' => 'verify_delete_request', + ], + 'mstv/v1/files/(?P\d+)/move' => ['POST' => 'verify_write_request'], + 'mstv/v1/files/(?P\d+)/download' => ['GET' => 'verify_read_request'], + 'mstv/v1/files/(?P\d+)/preview' => ['GET' => 'verify_read_request'], + 'mstv/v1/search' => ['GET' => 'verify_read_request'], + 'mstv/v1/settings' => [ + 'GET' => 'verify_admin_request', + 'POST' => 'verify_admin_request', + ], + 'mstv/v1/logs' => ['GET' => 'verify_admin_request'], + 'mstv/v1/users/search' => ['GET' => 'verify_admin_request'], + 'mstv/v1/export' => ['GET' => 'verify_read_request'], + 'mstv/v1/folders/(?P\d+)/export' => ['GET' => 'verify_read_request'], + ]; + + $map = $this->permission_map_for($this->register_file_routes()); + + self::assertSame($expected, $this->callback_methods($map)); + } + + public function test_governance_routes_match_the_expected_permission_map(): void + { + $expected = [ + 'mstv/v1/groups' => [ + 'GET' => 'verify_admin_request', + 'POST' => 'verify_admin_request', + ], + 'mstv/v1/groups/(?P\d+)' => [ + 'PATCH' => 'verify_admin_request', + 'DELETE' => 'verify_admin_request', + ], + 'mstv/v1/groups/(?P\d+)/members' => ['POST' => 'verify_admin_request'], + 'mstv/v1/settings/notifications' => [ + 'GET' => 'verify_admin_request', + 'POST' => 'verify_admin_request', + ], + 'mstv/v1/reports/access' => ['GET' => 'verify_admin_request'], + 'mstv/v1/quotas' => [ + 'GET' => 'verify_admin_request', + 'PUT' => 'verify_admin_request', + ], + 'mstv/v1/folders/(?P\d+)/permissions' => [ + 'GET' => 'verify_write_request', + 'PUT' => 'verify_write_request', + 'DELETE' => 'verify_write_request', + ], + ]; + + $map = $this->permission_map_for($this->register_governance_routes()); + + self::assertSame($expected, $this->callback_methods($map)); + } + + private function register_file_routes(): array + { + $GLOBALS['pdm_test_rest_routes'] = []; + + $controller = new MSTV_REST_Controller( + new MSTV_Settings(), + $this->createMock(MSTV_Auth::class), + $this->getMockBuilder(MSTV_Storage::class)->disableOriginalConstructor()->getMock(), + $this->createMock(MSTV_Validator::class), + $this->createMock(MSTV_Repository_Folders::class), + $this->getMockBuilder(MSTV_Repository_Files::class)->disableOriginalConstructor()->getMock(), + $this->getMockBuilder(MSTV_Download::class)->disableOriginalConstructor()->getMock(), + $this->getMockBuilder(MSTV_Preview::class)->disableOriginalConstructor()->getMock(), + $this->createMock(MSTV_Logger::class) + ); + $controller->register_routes(); + + return $GLOBALS['pdm_test_rest_routes']; + } + + private function register_governance_routes(): array + { + $GLOBALS['pdm_test_rest_routes'] = []; + + $controller = new MSTV_REST_Governance_Controller( + $this->createMock(MSTV_Auth::class), + $this->getMockBuilder(MSTV_Repository_Groups::class)->disableOriginalConstructor()->getMock(), + $this->getMockBuilder(MSTV_Repository_Permissions::class)->disableOriginalConstructor()->getMock(), + $this->createMock(MSTV_Repository_Folders::class), + $this->createMock(MSTV_Permissions::class), + null + ); + $controller->register_routes(); + + return $GLOBALS['pdm_test_rest_routes']; + } + + /** + * @param array> $routes + * @return array> + */ + private function permission_map_for(array $routes): array + { + $map = []; + + foreach ($routes as $path => $registrations) { + foreach ($registrations as $args) { + if (!is_array($args)) { + continue; + } + + $entries = isset($args['methods']) ? [$args] : array_values(array_filter($args, 'is_array')); + + foreach ($entries as $entry) { + $methods = $entry['methods'] ?? []; + $methods = is_array($methods) ? $methods : [$methods]; + + foreach ($methods as $method) { + $map[$path][(string) $method] = $entry['permission_callback'] ?? null; + } + } + } + } + + return $map; + } + + /** + * @param array>> $map + * @return array> + */ + private function callback_methods(array $map): array + { + $methods = []; + + foreach ($map as $path => $byMethod) { + foreach ($byMethod as $method => $callback) { + $methods[$path][$method] = is_array($callback) ? $callback[1] : null; + } + } + + return $methods; + } +} diff --git a/tests/PDMViewsEscapingTest.php b/tests/PDMViewsEscapingTest.php new file mode 100644 index 0000000..14f98fb --- /dev/null +++ b/tests/PDMViewsEscapingTest.php @@ -0,0 +1,150 @@ + 1, + 'user_id' => 5, + 'user_login' => '', + 'action' => '">', + 'target_type' => 'file', + 'target_id' => 7, + 'context' => '{"filename":"Report"}', + 'ip_address' => '1.2.3.4" onmouseover="alert(3)', + 'created_at' => '2026-01-01 10:00:00', + ]; + + $html = $this->render_view('logs-page.php', [ + 'mstv_logs' => [$log], + 'mstv_pagination' => [ + 'page' => 1, + 'per_page' => 50, + 'total_items' => 1, + 'total_pages' => 1, + 'has_prev' => false, + 'has_next' => false, + 'from_item' => 1, + 'to_item' => 1, + ], + 'mstv_current_page' => 1, + 'mstv_selected_per_page' => 50, + 'mstv_allowed_per_page' => [25, 50, 100, 200], + ]); + + self::assertStringNotContainsString('', + ]; + $GLOBALS['pdm_test_users'] = [ + 7 => new FakePDMViewUser(7, '', '">'), + ]; + + $html = $this->render_view('settings-page.php', [ + 'settings' => new MSTV_Settings(), + 'orphaned_files_count' => 0, + 'cleanup_result' => false, + 'reindex_result' => false, + ]); + + self::assertStringNotContainsString('
0) : ?> - + - +
@@ -133,7 +139,13 @@ function mstv_get_log_target_label(string $targetType): string 1) : ?>
- +
@@ -145,7 +157,12 @@ function mstv_get_log_target_label(string $targetType): string - + diff --git a/assets/js/admin-app-core.js b/assets/js/admin-app-core.js index 1631fb3..07cc14e 100644 --- a/assets/js/admin-app-core.js +++ b/assets/js/admin-app-core.js @@ -1340,7 +1340,7 @@ this.showToast(response.message || mstvConfig.i18n.folderCreateError, 'error'); } } catch (error) { - this.showToast(error.message || mstvConfig.i18n.folderCreateError, 'error'); + this.showToast(this.apiErrorMessage(error, 'folderCreateError'), 'error'); console.error('Create folder error:', error); } }, @@ -1357,7 +1357,7 @@ this.showToast(response.message || mstvConfig.i18n.renameError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.renameError, 'error'); + this.showToast(this.apiErrorMessage(error, 'renameError'), 'error'); console.error('Rename folder error:', error); } }, @@ -1379,7 +1379,7 @@ this.showToast(response.message || mstvConfig.i18n.deleteError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.deleteError, 'error'); + this.showToast(this.apiErrorMessage(error, 'deleteError'), 'error'); console.error('Delete folder error:', error); } }, @@ -1396,7 +1396,7 @@ this.showToast(response.message || mstvConfig.i18n.renameError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.renameError, 'error'); + this.showToast(this.apiErrorMessage(error, 'renameError'), 'error'); console.error('Rename files error:', error); } }, @@ -1419,7 +1419,7 @@ this.showToast(response.message || mstvConfig.i18n.deleteError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.deleteError, 'error'); + this.showToast(this.apiErrorMessage(error, 'deleteError'), 'error'); console.error('Delete files error:', error); } }, @@ -1443,7 +1443,7 @@ this.showToast(response.message || mstvConfig.i18n.moveError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.moveError, 'error'); + this.showToast(this.apiErrorMessage(error, 'moveError'), 'error'); console.error('Move files error:', error); } }, @@ -1467,7 +1467,7 @@ this.showToast(response.message || mstvConfig.i18n.folderMoveError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.folderMoveError, 'error'); + this.showToast(this.apiErrorMessage(error, 'folderMoveError'), 'error'); console.error('Move folder error:', error); } }, @@ -1702,7 +1702,7 @@ if (error.name === 'AbortError') { return false; } - this.showToast(error.message || mstvConfig.i18n.uploadError, 'error'); + this.showToast(this.apiErrorMessage(error, 'uploadError'), 'error'); console.error('Upload error:', error); return false; } @@ -2356,6 +2356,15 @@ return this.folderCan(folder, 'manage'); }, + apiErrorMessage(error, i18nKey) { + // Only server-provided API messages reach the user; network and + // programming errors fall back to the localized generic string. + if (error && error.isApiError && typeof error.message === 'string' && error.message) { + return error.message; + } + return mstvConfig.i18n[i18nKey] || mstvConfig.i18n.errorGeneric; + }, + applyToolbarPermissions() { const toggle = (el, allowed) => { if (!el) return; diff --git a/assets/js/admin-app-governance.js b/assets/js/admin-app-governance.js index 0ab2d13..d89ed6d 100644 --- a/assets/js/admin-app-governance.js +++ b/assets/js/admin-app-governance.js @@ -175,7 +175,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -192,7 +192,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -390,7 +390,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -406,7 +406,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -555,7 +555,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -633,8 +633,10 @@ }); if (f.date_from) params.set('date_from', f.date_from); if (f.date_to) params.set('date_to', f.date_to); - // The report "action" select is preview/download; pass it through as a log action filter. - if (f.action) params.set('action', f.action); + // The report "action" select is preview/download; pass it through as + // a log action filter. It must not collide with the admin-post routing + // parameter, so it travels under a dedicated key. + if (f.action) params.set('log_action', f.action); const url = new URL(actionUrl); params.forEach((value, key) => url.searchParams.set(key, value)); window.location.href = url.toString(); @@ -790,7 +792,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -800,7 +802,10 @@ if (contentType.includes('application/json')) { const data = await response.json(); if (!response.ok && !data.success) { - throw new Error(data.message || `HTTP ${response.status}`); + throw Object.assign( + new Error(data.message || `HTTP ${response.status}`), + { isApiError: true } + ); } return data; } @@ -811,7 +816,10 @@ .replace(/\s+/g, ' ') .trim(); - throw new Error(message || `HTTP ${response.status}`); + throw Object.assign( + new Error(message || `HTTP ${response.status}`), + { isApiError: true } + ); }, }); })(); diff --git a/includes/class-mstv-admin.php b/includes/class-mstv-admin.php index f8540a5..a6dce53 100644 --- a/includes/class-mstv-admin.php +++ b/includes/class-mstv-admin.php @@ -502,9 +502,10 @@ public function handle_export_audit_csv(): void // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Nonce verified above; reading read-only filter values. $filters = []; - foreach (['date_from', 'date_to', 'action', 'file_type'] as $key) { + foreach (['date_from', 'date_to', 'log_action', 'file_type'] as $key) { if (isset($_GET[$key]) && $_GET[$key] !== '') { - $filters[$key] = sanitize_text_field(wp_unslash($_GET[$key])); + $filterKey = $key === 'log_action' ? 'action' : $key; + $filters[$filterKey] = sanitize_text_field(wp_unslash($_GET[$key])); } } if (isset($_GET['user_id']) && $_GET['user_id'] !== '') { diff --git a/includes/class-mstv-i18n.php b/includes/class-mstv-i18n.php index a4b38c8..4361dbf 100644 --- a/includes/class-mstv-i18n.php +++ b/includes/class-mstv-i18n.php @@ -141,7 +141,6 @@ class MSTV_I18n 'Error while uploading the file.' => 'Errore durante il caricamento del file.', 'Uploading...' => 'Caricamento in corso...', 'Upload cancelled.' => 'Caricamento annullato.', - 'Entries' => 'voci', 'Export' => 'Esporta', 'Export All' => 'Esporta tutto', 'Create a ZIP archive with the full private document library.' => 'Crea un archivio ZIP con l\'intera libreria privata di documenti.', @@ -278,6 +277,7 @@ class MSTV_I18n 'The name cannot be empty.' => 'Il nome non può essere vuoto.', 'The new name is identical to the current name.' => 'Il nuovo nome è identico al nome attuale.', 'This file type is not allowed.' => 'Tipo di file non consentito.', + 'The file content does not match its extension: the file appears to be %1$s but is named .%2$s.' => 'Il contenuto del file non corrisponde alla sua estensione: il file sembra essere %1$s ma è denominato .%2$s.', 'This folder is empty' => 'Questa cartella è vuota', 'This name is reserved by the system.' => 'Questo nome è riservato dal sistema.', 'Total' => 'Totale', @@ -323,6 +323,9 @@ class MSTV_I18n 'German' => 'Tedesco', 'English' => 'Inglese', 'Custom permissions' => 'Permessi personalizzati', + 'Showing %1$d-%2$d of %3$d entries' => 'Visualizzazione %1$d-%2$d di %3$d voci', + 'Page %1$d of %2$d' => 'Pagina %1$d di %2$d', + 'No entries' => 'Nessuna voce', ]; private const FRENCH_MAP = [ @@ -453,7 +456,6 @@ class MSTV_I18n 'Error while uploading the file.' => 'Erreur lors du téléversement du fichier.', 'Uploading...' => 'Téléversement en cours...', 'Upload cancelled.' => 'Téléversement annulé.', - 'Entries' => 'entrées', 'Export' => 'Exporter', 'Export All' => 'Tout exporter', 'Create a ZIP archive with the full private document library.' => 'Créez une archive ZIP contenant l\'intégralité de la bibliothèque de documents privés.', @@ -590,6 +592,7 @@ class MSTV_I18n 'The name cannot be empty.' => 'Le nom ne peut pas être vide.', 'The new name is identical to the current name.' => 'Le nouveau nom est identique au nom actuel.', 'This file type is not allowed.' => 'Ce type de fichier n\'est pas autorisé.', + 'The file content does not match its extension: the file appears to be %1$s but is named .%2$s.' => 'Le contenu du fichier ne correspond pas à son extension : le fichier semble être %1$s mais il est nommé .%2$s.', 'This folder is empty' => 'Ce dossier est vide', 'This name is reserved by the system.' => 'Ce nom est réservé par le système.', 'Total' => 'Total', @@ -635,6 +638,9 @@ class MSTV_I18n 'German' => 'Allemand', 'English' => 'Anglais', 'Custom permissions' => 'Autorisations personnalisées', + 'Showing %1$d-%2$d of %3$d entries' => 'Affichage de %1$d-%2$d sur %3$d entrées', + 'Page %1$d of %2$d' => 'Page %1$d sur %2$d', + 'No entries' => 'Aucune entrée', ]; private const SPANISH_MAP = [ @@ -765,7 +771,6 @@ class MSTV_I18n 'Error while uploading the file.' => 'Error al subir el archivo.', 'Uploading...' => 'Subiendo...', 'Upload cancelled.' => 'Subida cancelada.', - 'Entries' => 'entradas', 'Export' => 'Exportar', 'Export All' => 'Exportar todo', 'Create a ZIP archive with the full private document library.' => 'Crea un archivo ZIP con toda la biblioteca de documentos privados.', @@ -902,6 +907,7 @@ class MSTV_I18n 'The name cannot be empty.' => 'El nombre no puede estar vacío.', 'The new name is identical to the current name.' => 'El nuevo nombre es idéntico al nombre actual.', 'This file type is not allowed.' => 'Este tipo de archivo no está permitido.', + 'The file content does not match its extension: the file appears to be %1$s but is named .%2$s.' => 'El contenido del archivo no coincide con su extensión: el archivo parece ser %1$s pero se llama .%2$s.', 'This folder is empty' => 'Esta carpeta está vacía', 'This name is reserved by the system.' => 'Este nombre está reservado por el sistema.', 'Total' => 'Total', @@ -947,6 +953,9 @@ class MSTV_I18n 'German' => 'Alemán', 'English' => 'Inglés', 'Custom permissions' => 'Permisos personalizados', + 'Showing %1$d-%2$d of %3$d entries' => 'Mostrando %1$d-%2$d de %3$d entradas', + 'Page %1$d of %2$d' => 'Página %1$d de %2$d', + 'No entries' => 'Sin entradas', ]; private const GERMAN_MAP = [ @@ -1077,7 +1086,6 @@ class MSTV_I18n 'Error while uploading the file.' => 'Fehler beim Hochladen der Datei.', 'Uploading...' => 'Wird hochgeladen...', 'Upload cancelled.' => 'Upload abgebrochen.', - 'Entries' => 'Einträge', 'Export' => 'Exportieren', 'Export All' => 'Alle exportieren', 'Create a ZIP archive with the full private document library.' => 'Erstellen Sie ein ZIP-Archiv mit der gesamten privaten Dokumentbibliothek.', @@ -1214,6 +1222,7 @@ class MSTV_I18n 'The name cannot be empty.' => 'Der Name darf nicht leer sein.', 'The new name is identical to the current name.' => 'Der neue Name ist mit dem aktuellen Namen identisch.', 'This file type is not allowed.' => 'Dieser Dateityp ist nicht erlaubt.', + 'The file content does not match its extension: the file appears to be %1$s but is named .%2$s.' => 'Der Dateiinhalt stimmt nicht mit der Endung überein: Die Datei scheint %1$s zu sein, ist aber mit .%2$s benannt.', 'This folder is empty' => 'Dieser Ordner ist leer', 'This name is reserved by the system.' => 'Dieser Name ist vom System reserviert.', 'Total' => 'Gesamt', @@ -1259,6 +1268,9 @@ class MSTV_I18n 'German' => 'Deutsch', 'English' => 'Englisch', 'Custom permissions' => 'Benutzerdefinierte Berechtigungen', + 'Showing %1$d-%2$d of %3$d entries' => 'Zeige %1$d-%2$d von %3$d Einträgen', + 'Page %1$d of %2$d' => 'Seite %1$d von %2$d', + 'No entries' => 'Keine Einträge', ]; public function init(): void diff --git a/includes/class-mstv-validator.php b/includes/class-mstv-validator.php index c90561c..7ee0310 100644 --- a/includes/class-mstv-validator.php +++ b/includes/class-mstv-validator.php @@ -247,7 +247,12 @@ public function validate_upload(array $files): array if (!$this->validate_mime_type($detectedMime)) { $errors[] = __('This file type is not allowed.', 'mikesoft-teamvault'); } elseif ($extension !== '' && !$this->validate_extension_mime_pair($extension, $detectedMime)) { - $errors[] = __('This file type is not allowed.', 'mikesoft-teamvault'); + $errors[] = sprintf( + /* translators: 1: content type detected inside the file, 2: file extension. */ + __('The file content does not match its extension: the file appears to be %1$s but is named .%2$s.', 'mikesoft-teamvault'), + $detectedMime, + $extension + ); } return [ diff --git a/tests/PDMAdminAppTest.php b/tests/PDMAdminAppTest.php index 0244863..df8e30f 100644 --- a/tests/PDMAdminAppTest.php +++ b/tests/PDMAdminAppTest.php @@ -164,4 +164,36 @@ public function testDetailsDrawerCssAccountsForTheAdminMenuAndRespectsAdminBarOf 'The responsive details behavior must not leave a cramped inline details panel.' ); } + + public function testDeleteAndMutationErrorsSurfaceServerMessages(): void + { + $source = $this->adminAppSource(); + + self::assertStringContainsString( + "apiErrorMessage(error, 'deleteError')", + $source, + 'Delete failures must surface the server-provided message instead of a generic error.' + ); + self::assertStringContainsString( + 'isApiError: true', + $source, + 'API errors must be marked so network failures can fall back to the generic message.' + ); + } + + public function testAuditCsvFilterTravelsUnderADedicatedKey(): void + { + $source = $this->adminAppSource(); + + self::assertStringContainsString( + "params.set('log_action', f.action);", + $source, + 'The report action filter must not overwrite the admin-post routing parameter.' + ); + self::assertStringNotContainsString( + "params.set('action', f.action);", + $source, + 'The audit CSV export must not reuse the routing parameter name for filters.' + ); + } } diff --git a/tests/PDMAdminCsvTest.php b/tests/PDMAdminCsvTest.php index 7e149ed..29e785b 100644 --- a/tests/PDMAdminCsvTest.php +++ b/tests/PDMAdminCsvTest.php @@ -27,4 +27,19 @@ public function test_audit_csv_neutralizes_spreadsheet_formula_cells(): void self::assertSame("'=external-reference", $row[6]); self::assertSame('editor', $row[1]); } + + public function test_audit_csv_handler_reads_the_action_filter_under_log_action(): void + { + $source = (string) file_get_contents(__DIR__ . '/../includes/class-mstv-admin.php'); + + self::assertStringContainsString( + "['date_from', 'date_to', 'log_action', 'file_type']", + $source, + 'The audit CSV handler must read the action filter under log_action so it cannot collide with the admin-post routing parameter.' + ); + self::assertStringNotContainsString( + "foreach (['date_from', 'date_to', 'action', 'file_type'] as \$key)", + $source + ); + } } diff --git a/tests/PDMViewsEscapingTest.php b/tests/PDMViewsEscapingTest.php index 14f98fb..9ae9e43 100644 --- a/tests/PDMViewsEscapingTest.php +++ b/tests/PDMViewsEscapingTest.php @@ -73,6 +73,7 @@ public function test_logs_page_escapes_log_row_values(): void self::assertStringNotContainsString('