From a93f1fee1e3a9201d7a7bbcd691297fbc02a0cef Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 24 Mar 2026 18:04:27 +0200 Subject: [PATCH] feat: edit history visibility settings --- .lintstagedrc.cjs | 2 +- lib/Config/ConfigLexicon.php | 3 + lib/Controller/AdminController.php | 10 +- lib/Controller/PostController.php | 43 ++++- lib/Controller/PostHistoryController.php | 14 ++ lib/Service/AdminSettingsService.php | 17 +- lib/Service/EditHistoryVisibilityService.php | 69 +++++++ lib/Service/PostEnrichmentService.php | 14 ++ lib/Service/UserPreferencesService.php | 5 + openapi-full.json | 12 ++ openapi.json | 12 ++ src/components/PostCard/PostCard.test.ts | 15 +- src/components/PostCard/PostCard.vue | 2 +- src/composables/usePublicSettings.ts | 4 + src/types/models.ts | 1 + src/views/UserPreferencesView.vue | 41 ++++- src/views/admin/AdminGeneralSettings.vue | 42 ++++- tests/unit/Controller/AdminControllerTest.php | 87 +++++++++ .../Controller/PostHistoryControllerTest.php | 46 ++++- .../unit/Service/AdminSettingsServiceTest.php | 16 +- .../EditHistoryVisibilityServiceTest.php | 172 ++++++++++++++++++ .../Service/UserPreferencesServiceTest.php | 12 +- 22 files changed, 608 insertions(+), 31 deletions(-) create mode 100644 lib/Service/EditHistoryVisibilityService.php create mode 100644 tests/unit/Service/EditHistoryVisibilityServiceTest.php diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index 8a7d571..14c1a66 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -13,5 +13,5 @@ module.exports = { } return commands }, - '*Controller.php': [() => 'make openapi', () => 'git add openapi.json'], + '*Controller.php': [() => 'make openapi', () => 'git add openapi-*.json'], } diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 249a4fe..8b3f5f5 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -23,6 +23,8 @@ class ConfigLexicon implements ILexicon { new Entry('subtitle', ValueType::STRING, 'Welcome to the forum!', 'Forum subtitle displayed below the title', lazy: true), new Entry('allow_guest_access', ValueType::BOOL, false, 'Whether unauthenticated users can access the forum', lazy: true), new Entry('is_initialized', ValueType::BOOL, false, 'Whether the forum has been initialized with seed data', lazy: true), + new Entry('public_edit_history', ValueType::BOOL, true, 'Whether all users can view edit history of posts', lazy: true), + new Entry('allow_edit_history_user_override', ValueType::BOOL, false, 'Whether users can hide their own edit history from others', lazy: true), ]; } @@ -31,6 +33,7 @@ class ConfigLexicon implements ILexicon { new Entry('auto_subscribe_created_threads', ValueType::BOOL, true, 'Automatically subscribe to threads the user creates'), new Entry('auto_subscribe_replied_threads', ValueType::BOOL, false, 'Automatically subscribe to threads the user replies to'), new Entry('upload_directory', ValueType::STRING, 'Forum', 'Directory in user storage for forum file uploads'), + new Entry('hide_edit_history', ValueType::BOOL, false, 'Whether to hide edit history from other users'), ]; } } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 472204e..9ee412e 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -201,6 +201,8 @@ class AdminController extends OCSController { * @param string|null $title Forum title * @param string|null $subtitle Forum subtitle * @param bool|null $allow_guest_access Allow unauthenticated users to view forum content + * @param bool|null $public_edit_history Whether all users can view edit history of posts + * @param bool|null $allow_edit_history_user_override Whether users can hide their own edit history from others * @return DataResponse, array{}> * * 200: Settings updated @@ -208,7 +210,7 @@ class AdminController extends OCSController { #[NoAdminRequired] #[RequirePermission('canAccessAdminTools')] #[ApiRoute(verb: 'PUT', url: '/api/admin/settings')] - public function updateSettings(?string $title = null, ?string $subtitle = null, ?bool $allow_guest_access = null): DataResponse { + public function updateSettings(?string $title = null, ?string $subtitle = null, ?bool $allow_guest_access = null, ?bool $public_edit_history = null, ?bool $allow_edit_history_user_override = null): DataResponse { try { // Build settings array with only non-null values $settingsToUpdate = []; @@ -221,6 +223,12 @@ class AdminController extends OCSController { if ($allow_guest_access !== null) { $settingsToUpdate[AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS] = $allow_guest_access; } + if ($public_edit_history !== null) { + $settingsToUpdate[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY] = $public_edit_history; + } + if ($allow_edit_history_user_override !== null) { + $settingsToUpdate[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE] = $allow_edit_history_user_override; + } // Update settings and return all settings $settings = $this->settingsService->updateSettings($settingsToUpdate); diff --git a/lib/Controller/PostController.php b/lib/Controller/PostController.php index 1c24483..545b4ad 100644 --- a/lib/Controller/PostController.php +++ b/lib/Controller/PostController.php @@ -106,10 +106,13 @@ class PostController extends OCSController { // Batch fetch author data (includes roles) $authors = $this->userService->enrichMultipleUsers($authorIds); + // Get category ID for permission checks + $categoryId = $this->permissionService->getCategoryIdFromThread($threadId); + // Enrich posts with content, reactions, and pre-fetched author data - return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) { + return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors, $categoryId) { $postReactions = $reactionsByPostId[$p->getId()] ?? []; - return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()]); + return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()], $categoryId); }, $posts)); } catch (\Exception $e) { $this->logger->error('Error fetching posts by thread: ' . $e->getMessage()); @@ -206,6 +209,9 @@ class PostController extends OCSController { // Batch fetch author data (includes roles) $authors = $this->userService->enrichMultipleUsers($authorIds); + // Get category ID for permission checks + $categoryId = $this->permissionService->getCategoryIdFromThread($threadId); + // Enrich first post $enrichedFirstPost = null; if ($firstPost !== null) { @@ -215,14 +221,15 @@ class PostController extends OCSController { $bbcodes, $firstPostReactions, $currentUserId, - $authors[$firstPost->getAuthorId()] ?? null + $authors[$firstPost->getAuthorId()] ?? null, + $categoryId, ); } // Enrich replies - $enrichedReplies = array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) { + $enrichedReplies = array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors, $categoryId) { $postReactions = $reactionsByPostId[$p->getId()] ?? []; - return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()] ?? null); + return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()] ?? null, $categoryId); }, $replies); return new DataResponse([ @@ -283,11 +290,23 @@ class PostController extends OCSController { // For posts by a single author, we can optimize by fetching author data once $author = $this->userService->enrichUserData($authorId); + // Resolve category IDs for each post's thread (for edit history visibility) + $threadIds = array_unique(array_map(fn ($p) => $p->getThreadId(), $posts)); + $categoryByThread = []; + foreach ($threadIds as $tid) { + try { + $categoryByThread[$tid] = $this->permissionService->getCategoryIdFromThread($tid); + } catch (\Exception $e) { + // Skip if thread not found + } + } + // Enrich posts with content, reactions, pre-fetched author data, and page number $perPage = 20; - return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author, $perPage) { + return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author, $perPage, $categoryByThread) { $postReactions = $reactionsByPostId[$p->getId()] ?? []; - $enriched = $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $author); + $categoryId = $categoryByThread[$p->getThreadId()] ?? null; + $enriched = $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $author, $categoryId); // Calculate the page number for direct linking if (!$p->getIsFirstPost()) { @@ -321,7 +340,9 @@ class PostController extends OCSController { public function show(int $id): DataResponse { try { $post = $this->postMapper->find($id); - return new DataResponse($this->postEnrichmentService->enrichPost($post)); + $currentUserId = $this->userSession->getUser()?->getUID(); + $categoryId = $this->permissionService->getCategoryIdFromPost($id); + return new DataResponse($this->postEnrichmentService->enrichPost($post, [], [], $currentUserId, null, $categoryId)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { @@ -445,7 +466,9 @@ class PostController extends OCSController { $this->logger->warning('Failed to send mention notifications: ' . $e->getMessage()); } - return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED); + $currentUserId = $user?->getUID(); + $categoryId = $this->permissionService->getCategoryIdFromThread($threadId); + return new DataResponse($this->postEnrichmentService->enrichPost($createdPost, [], [], $currentUserId, null, $categoryId), Http::STATUS_CREATED); } catch (\Exception $e) { $this->logger->error('Error creating post: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to create post'], Http::STATUS_INTERNAL_SERVER_ERROR); @@ -517,7 +540,7 @@ class PostController extends OCSController { } } - return new DataResponse($this->postEnrichmentService->enrichPost($updatedPost)); + return new DataResponse($this->postEnrichmentService->enrichPost($updatedPost, [], [], $user->getUID(), null, $categoryId)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { diff --git a/lib/Controller/PostHistoryController.php b/lib/Controller/PostHistoryController.php index 7699fde..3a0a002 100644 --- a/lib/Controller/PostHistoryController.php +++ b/lib/Controller/PostHistoryController.php @@ -8,6 +8,9 @@ declare(strict_types=1); namespace OCA\Forum\Controller; use OCA\Forum\Attribute\RequirePermission; +use OCA\Forum\Db\PostMapper; +use OCA\Forum\Service\EditHistoryVisibilityService; +use OCA\Forum\Service\PermissionService; use OCA\Forum\Service\PostHistoryService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -23,7 +26,11 @@ class PostHistoryController extends OCSController { string $appName, IRequest $request, private PostHistoryService $postHistoryService, + private PostMapper $postMapper, + private PermissionService $permissionService, + private EditHistoryVisibilityService $editHistoryVisibilityService, private LoggerInterface $logger, + private ?string $userId, ) { parent::__construct($appName, $request); } @@ -44,6 +51,13 @@ class PostHistoryController extends OCSController { #[ApiRoute(verb: 'GET', url: '/api/posts/{postId}/history')] public function getHistory(int $postId): DataResponse { try { + $post = $this->postMapper->find($postId); + $categoryId = $this->permissionService->getCategoryIdFromPost($postId); + + if (!$this->editHistoryVisibilityService->canViewEditHistory($this->userId, $post->getAuthorId(), $categoryId)) { + return new DataResponse(['error' => 'Insufficient permissions to view edit history'], Http::STATUS_FORBIDDEN); + } + $history = $this->postHistoryService->getPostHistory($postId); return new DataResponse($history); } catch (DoesNotExistException $e) { diff --git a/lib/Service/AdminSettingsService.php b/lib/Service/AdminSettingsService.php index 5ad7026..92ea2c2 100644 --- a/lib/Service/AdminSettingsService.php +++ b/lib/Service/AdminSettingsService.php @@ -24,12 +24,20 @@ class AdminSettingsService { /** Setting key for initialization status */ public const SETTING_IS_INITIALIZED = 'is_initialized'; + /** Setting key for public edit history */ + public const SETTING_PUBLIC_EDIT_HISTORY = 'public_edit_history'; + + /** Setting key for allowing user override of edit history visibility */ + public const SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE = 'allow_edit_history_user_override'; + /** @var array List of valid setting keys */ private const VALID_KEYS = [ self::SETTING_TITLE, self::SETTING_SUBTITLE, self::SETTING_ALLOW_GUEST_ACCESS, self::SETTING_IS_INITIALIZED, + self::SETTING_PUBLIC_EDIT_HISTORY, + self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, ]; public function __construct( @@ -51,6 +59,8 @@ class AdminSettingsService { self::SETTING_SUBTITLE => $this->l10n->t('Welcome to the forum!'), self::SETTING_ALLOW_GUEST_ACCESS => false, self::SETTING_IS_INITIALIZED => false, + self::SETTING_PUBLIC_EDIT_HISTORY => true, + self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false, default => null, }; } @@ -86,7 +96,9 @@ class AdminSettingsService { return match ($key) { self::SETTING_ALLOW_GUEST_ACCESS, - self::SETTING_IS_INITIALIZED => $this->config->getAppValueBool($key, $default, true), + self::SETTING_IS_INITIALIZED, + self::SETTING_PUBLIC_EDIT_HISTORY, + self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => $this->config->getAppValueBool($key, $default, true), default => $this->config->getAppValueString($key, $default, true), }; } @@ -127,7 +139,8 @@ class AdminSettingsService { throw new \InvalidArgumentException("Invalid setting key: $key"); } - if ($key === self::SETTING_ALLOW_GUEST_ACCESS || $key === self::SETTING_IS_INITIALIZED) { + if ($key === self::SETTING_ALLOW_GUEST_ACCESS || $key === self::SETTING_IS_INITIALIZED + || $key === self::SETTING_PUBLIC_EDIT_HISTORY || $key === self::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE) { $this->config->setAppValueBool($key, (bool)$value, true); } else { $this->config->setAppValueString($key, (string)$value, true); diff --git a/lib/Service/EditHistoryVisibilityService.php b/lib/Service/EditHistoryVisibilityService.php new file mode 100644 index 0000000..2ee9664 --- /dev/null +++ b/lib/Service/EditHistoryVisibilityService.php @@ -0,0 +1,69 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Service; + +/** + * Centralized logic for determining whether a user can view edit history of a post + */ +class EditHistoryVisibilityService { + public function __construct( + private AdminSettingsService $adminSettingsService, + private UserPreferencesService $userPreferencesService, + private PermissionService $permissionService, + ) { + } + + /** + * Determine whether a viewer can see the edit history of a post + * + * @param string|null $viewerId The user viewing (null for guests) + * @param string $postAuthorId The author of the post + * @param int $categoryId The category the post belongs to + * @return bool Whether the viewer can see the edit history + */ + public function canViewEditHistory(?string $viewerId, string $postAuthorId, int $categoryId): bool { + // Owners always see their own history + if ($viewerId !== null && $viewerId === $postAuthorId) { + return true; + } + + // Global admins/moderators always see history + if ($viewerId !== null && $this->permissionService->hasAdminOrModeratorRole($viewerId)) { + return true; + } + + // Users with canModerate on the specific category always see history + if ($viewerId !== null && $this->permissionService->hasCategoryPermission($viewerId, $categoryId, 'canModerate')) { + return true; + } + + // Check admin setting: public edit history + $publicEditHistory = (bool)$this->adminSettingsService->getSetting( + AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY + ); + if (!$publicEditHistory) { + return false; + } + + // Check user override: post author can hide their edit history + $allowOverride = (bool)$this->adminSettingsService->getSetting( + AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE + ); + if ($allowOverride) { + $hideEditHistory = (bool)$this->userPreferencesService->getPreference( + $postAuthorId, + UserPreferencesService::PREF_HIDE_EDIT_HISTORY + ); + if ($hideEditHistory) { + return false; + } + } + + return true; + } +} diff --git a/lib/Service/PostEnrichmentService.php b/lib/Service/PostEnrichmentService.php index 5aeaebc..2a7c2b0 100644 --- a/lib/Service/PostEnrichmentService.php +++ b/lib/Service/PostEnrichmentService.php @@ -17,6 +17,7 @@ class PostEnrichmentService { private BBCodeService $bbCodeService, private BBCodeMapper $bbCodeMapper, private UserService $userService, + private EditHistoryVisibilityService $editHistoryVisibilityService, ) { } @@ -28,6 +29,7 @@ class PostEnrichmentService { * @param array $reactions Post reactions * @param string|null $currentUserId Current user ID for reaction status * @param array|null $author Optional pre-loaded author data + * @param int|null $categoryId Category ID for permission checks * @return array Enriched post data */ public function enrichPost( @@ -36,6 +38,7 @@ class PostEnrichmentService { array $reactions = [], ?string $currentUserId = null, ?array $author = null, + ?int $categoryId = null, ): array { if (!is_array($post)) { $post = $post->jsonSerialize(); @@ -57,6 +60,17 @@ class PostEnrichmentService { // Add reactions (grouped by emoji) $post['reactions'] = $this->groupReactions($reactions, $currentUserId); + // Add canViewHistory flag + if ($categoryId !== null && !empty($post['isEdited'])) { + $post['canViewHistory'] = $this->editHistoryVisibilityService->canViewEditHistory( + $currentUserId, + $post['authorId'], + $categoryId, + ); + } else { + $post['canViewHistory'] = false; + } + return $post; } diff --git a/lib/Service/UserPreferencesService.php b/lib/Service/UserPreferencesService.php index 279ee73..64b2b8c 100644 --- a/lib/Service/UserPreferencesService.php +++ b/lib/Service/UserPreferencesService.php @@ -26,12 +26,16 @@ class UserPreferencesService { /** Preference key for user signature (stored in forum_users table) */ public const PREF_SIGNATURE = 'signature'; + /** Preference key for hiding edit history from others */ + public const PREF_HIDE_EDIT_HISTORY = 'hide_edit_history'; + /** @var array Default preference values */ private const DEFAULTS = [ self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true, self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => false, self::PREF_UPLOAD_DIRECTORY => 'Forum', self::PREF_SIGNATURE => '', + self::PREF_HIDE_EDIT_HISTORY => false, ]; /** @var array List of valid preference keys */ @@ -40,6 +44,7 @@ class UserPreferencesService { self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS, self::PREF_UPLOAD_DIRECTORY, self::PREF_SIGNATURE, + self::PREF_HIDE_EDIT_HISTORY, ]; /** @var array Keys stored in forum_users table instead of config */ diff --git a/openapi-full.json b/openapi-full.json index b09ad0a..6c14983 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -371,6 +371,18 @@ "nullable": true, "default": null, "description": "Allow unauthenticated users to view forum content" + }, + "public_edit_history": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Whether all users can view edit history of posts" + }, + "allow_edit_history_user_override": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Whether users can hide their own edit history from others" } } } diff --git a/openapi.json b/openapi.json index ff4f4ff..1c57701 100644 --- a/openapi.json +++ b/openapi.json @@ -371,6 +371,18 @@ "nullable": true, "default": null, "description": "Allow unauthenticated users to view forum content" + }, + "public_edit_history": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Whether all users can view edit history of posts" + }, + "allow_edit_history_user_override": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Whether users can hide their own edit history from others" } } } diff --git a/src/components/PostCard/PostCard.test.ts b/src/components/PostCard/PostCard.test.ts index c44b817..f08d679 100644 --- a/src/components/PostCard/PostCard.test.ts +++ b/src/components/PostCard/PostCard.test.ts @@ -223,8 +223,8 @@ describe('PostCard', () => { expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false) }) - it('should show view history button when post is edited', () => { - const post = createMockPost({ isEdited: true }) + it('should show view history button when canViewHistory is true', () => { + const post = createMockPost({ isEdited: true, canViewHistory: true }) const wrapper = mount(PostCard, { props: { post }, }) @@ -232,8 +232,17 @@ describe('PostCard', () => { expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(true) }) + it('should not show view history button when canViewHistory is false', () => { + const post = createMockPost({ isEdited: true, canViewHistory: false }) + const wrapper = mount(PostCard, { + props: { post }, + }) + const buttons = wrapper.findAll('.nc-action-button') + expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(false) + }) + it('should not show view history button when post is not edited', () => { - const post = createMockPost({ isEdited: false }) + const post = createMockPost({ isEdited: false, canViewHistory: false }) const wrapper = mount(PostCard, { props: { post }, }) diff --git a/src/components/PostCard/PostCard.vue b/src/components/PostCard/PostCard.vue index a2e594c..ff81a10 100644 --- a/src/components/PostCard/PostCard.vue +++ b/src/components/PostCard/PostCard.vue @@ -42,7 +42,7 @@ {{ strings.delete }} - + diff --git a/src/composables/usePublicSettings.ts b/src/composables/usePublicSettings.ts index 271a307..f0f06d9 100644 --- a/src/composables/usePublicSettings.ts +++ b/src/composables/usePublicSettings.ts @@ -13,6 +13,10 @@ export interface PublicSettings { allow_guest_access: boolean /** Whether the forum has been initialized */ is_initialized: boolean + /** Whether all users can view edit history of posts */ + public_edit_history: boolean + /** Whether users can hide their own edit history from others */ + allow_edit_history_user_override: boolean } const settings = ref(null) diff --git a/src/types/models.ts b/src/types/models.ts index ccc39b6..4d9b267 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -90,6 +90,7 @@ export interface Post { // Thread context (added by SearchController for search results) threadTitle?: string threadSlug?: string + canViewHistory?: boolean // Client-side enrichment reactions?: Array<{ emoji: string diff --git a/src/views/UserPreferencesView.vue b/src/views/UserPreferencesView.vue index b3be04e..459f49f 100644 --- a/src/views/UserPreferencesView.vue +++ b/src/views/UserPreferencesView.vue @@ -97,6 +97,19 @@ + +
+

{{ strings.privacyTitle }}

+

{{ strings.privacyDesc }}

+ +
+ + {{ strings.hideEditHistory }} + +

{{ strings.hideEditHistoryHint }}

+
+
+
@@ -138,12 +151,14 @@ import FolderIcon from '@icons/Folder.vue' import { ocs } from '@/axios' import { t } from '@nextcloud/l10n' import { getFilePickerBuilder, FilePickerType } from '@nextcloud/dialogs' +import { usePublicSettings } from '@/composables/usePublicSettings' interface UserPreferences { auto_subscribe_created_threads: boolean auto_subscribe_replied_threads: boolean upload_directory: string signature: string + hide_edit_history: boolean } export default defineComponent({ @@ -162,6 +177,14 @@ export default defineComponent({ CheckIcon, FolderIcon, }, + setup() { + const { settings: publicSettings, fetchPublicSettings } = usePublicSettings() + fetchPublicSettings() + + return { + publicSettings, + } + }, data() { return { loading: false, @@ -173,12 +196,14 @@ export default defineComponent({ auto_subscribe_replied_threads: false, upload_directory: 'Forum', signature: '', + hide_edit_history: false, } as UserPreferences, formData: { auto_subscribe_created_threads: true, auto_subscribe_replied_threads: false, upload_directory: 'Forum', signature: '', + hide_edit_history: false, } as UserPreferences, strings: { @@ -219,6 +244,13 @@ export default defineComponent({ signatureLabel: t('forum', 'Signature'), signatureHint: t('forum', 'You can use BBCode formatting in your signature'), signaturePlaceholder: t('forum', 'Enter your signature …'), + privacyTitle: t('forum', 'Privacy'), + privacyDesc: t('forum', 'Control the visibility of your activity'), + hideEditHistory: t('forum', 'Hide my edit history from other accounts'), + hideEditHistoryHint: t( + 'forum', + 'When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history.', + ), }, } }, @@ -230,7 +262,14 @@ export default defineComponent({ this.formData.auto_subscribe_replied_threads !== this.originalData.auto_subscribe_replied_threads || this.formData.upload_directory !== this.originalData.upload_directory || - this.formData.signature !== this.originalData.signature + this.formData.signature !== this.originalData.signature || + this.formData.hide_edit_history !== this.originalData.hide_edit_history + ) + }, + showPrivacySection(): boolean { + return ( + !!this.publicSettings?.public_edit_history && + !!this.publicSettings?.allow_edit_history_user_override ) }, }, diff --git a/src/views/admin/AdminGeneralSettings.vue b/src/views/admin/AdminGeneralSettings.vue index 0365377..44d1862 100644 --- a/src/views/admin/AdminGeneralSettings.vue +++ b/src/views/admin/AdminGeneralSettings.vue @@ -57,6 +57,25 @@
+ +
+ + {{ strings.publicEditHistory }} + +

{{ strings.publicEditHistoryHint }}

+
+ +
+ + {{ strings.allowEditHistoryUserOverride }} + +

{{ strings.allowEditHistoryUserOverrideHint }}

+
+
+
@@ -101,6 +120,8 @@ interface Settings { title: string subtitle: string allow_guest_access: boolean + public_edit_history: boolean + allow_edit_history_user_override: boolean } export default defineComponent({ @@ -134,11 +155,15 @@ export default defineComponent({ title: '', subtitle: '', allow_guest_access: false, + public_edit_history: true, + allow_edit_history_user_override: false, } as Settings, formData: { title: '', subtitle: '', allow_guest_access: false, + public_edit_history: true, + allow_edit_history_user_override: false, } as Settings, strings: { @@ -162,6 +187,18 @@ export default defineComponent({ 'forum', 'When enabled, unauthenticated users can view forum content in read-only mode', ), + editHistoryTitle: t('forum', 'Edit history'), + editHistoryDesc: t('forum', 'Control who can view the edit history of posts'), + publicEditHistory: t('forum', 'Allow all accounts to view edit history'), + publicEditHistoryHint: t( + 'forum', + 'When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history.', + ), + allowEditHistoryUserOverride: t('forum', 'Allow accounts to hide their own edit history'), + allowEditHistoryUserOverrideHint: t( + 'forum', + 'When enabled, accounts can choose to hide their edit history from other accounts in their preferences.', + ), save: t('forum', 'Save'), cancel: t('forum', 'Cancel'), saveSuccess: t('forum', 'Settings saved'), @@ -173,7 +210,10 @@ export default defineComponent({ return ( this.formData.title !== this.originalData.title || this.formData.subtitle !== this.originalData.subtitle || - this.formData.allow_guest_access !== this.originalData.allow_guest_access + this.formData.allow_guest_access !== this.originalData.allow_guest_access || + this.formData.public_edit_history !== this.originalData.public_edit_history || + this.formData.allow_edit_history_user_override !== + this.originalData.allow_edit_history_user_override ) }, }, diff --git a/tests/unit/Controller/AdminControllerTest.php b/tests/unit/Controller/AdminControllerTest.php index a0b0a87..82456aa 100644 --- a/tests/unit/Controller/AdminControllerTest.php +++ b/tests/unit/Controller/AdminControllerTest.php @@ -81,6 +81,93 @@ class AdminControllerTest extends TestCase { ); } + // ── Settings tests ─────────────────────────────────────────────── + + public function testGetSettingsReturnsAllSettings(): void { + $allSettings = [ + 'title' => 'My Forum', + 'subtitle' => 'Welcome!', + 'allow_guest_access' => false, + 'is_initialized' => true, + 'public_edit_history' => true, + 'allow_edit_history_user_override' => false, + ]; + + $this->settingsService->expects($this->once()) + ->method('getAllSettings') + ->willReturn($allSettings); + + $response = $this->controller->getSettings(); + + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals($allSettings, $response->getData()); + } + + public function testUpdateSettingsPassesAllFieldsToService(): void { + $expectedUpdate = [ + AdminSettingsService::SETTING_TITLE => 'New Title', + AdminSettingsService::SETTING_SUBTITLE => 'New Subtitle', + AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true, + AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => false, + AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => true, + ]; + + $this->settingsService->expects($this->once()) + ->method('updateSettings') + ->with($expectedUpdate) + ->willReturn(array_merge($expectedUpdate, ['is_initialized' => true])); + + $response = $this->controller->updateSettings( + 'New Title', + 'New Subtitle', + true, + false, + true, + ); + + $this->assertEquals(200, $response->getStatus()); + } + + public function testUpdateSettingsOmitsNullValues(): void { + $this->settingsService->expects($this->once()) + ->method('updateSettings') + ->with($this->callback(function (array $settings) { + // Only public_edit_history and allow_edit_history_user_override should be present + return count($settings) === 2 + && array_key_exists(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, $settings) + && array_key_exists(AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, $settings) + && $settings[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY] === true + && $settings[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE] === true; + })) + ->willReturn([]); + + $response = $this->controller->updateSettings( + null, + null, + null, + true, + true, + ); + + $this->assertEquals(200, $response->getStatus()); + } + + public function testUpdateSettingsHandlesException(): void { + $this->settingsService->expects($this->once()) + ->method('updateSettings') + ->willThrowException(new \Exception('DB error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Error updating settings')); + + $response = $this->controller->updateSettings('Title'); + + $this->assertEquals(500, $response->getStatus()); + } + + // ── Dashboard tests ───────────────────────────────────────────── + public function testDashboardEnrichesContributorsWithDisplayNames(): void { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('admin'); diff --git a/tests/unit/Controller/PostHistoryControllerTest.php b/tests/unit/Controller/PostHistoryControllerTest.php index 03ec164..f834bab 100644 --- a/tests/unit/Controller/PostHistoryControllerTest.php +++ b/tests/unit/Controller/PostHistoryControllerTest.php @@ -6,6 +6,10 @@ namespace OCA\Forum\Tests\Controller; use OCA\Forum\AppInfo\Application; use OCA\Forum\Controller\PostHistoryController; +use OCA\Forum\Db\Post; +use OCA\Forum\Db\PostMapper; +use OCA\Forum\Service\EditHistoryVisibilityService; +use OCA\Forum\Service\PermissionService; use OCA\Forum\Service\PostHistoryService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -18,6 +22,12 @@ class PostHistoryControllerTest extends TestCase { private PostHistoryController $controller; /** @var PostHistoryService&MockObject */ private PostHistoryService $postHistoryService; + /** @var PostMapper&MockObject */ + private PostMapper $postMapper; + /** @var PermissionService&MockObject */ + private PermissionService $permissionService; + /** @var EditHistoryVisibilityService&MockObject */ + private EditHistoryVisibilityService $editHistoryVisibilityService; /** @var LoggerInterface&MockObject */ private LoggerInterface $logger; /** @var IRequest&MockObject */ @@ -26,18 +36,37 @@ class PostHistoryControllerTest extends TestCase { protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->postHistoryService = $this->createMock(PostHistoryService::class); + $this->postMapper = $this->createMock(PostMapper::class); + $this->permissionService = $this->createMock(PermissionService::class); + $this->editHistoryVisibilityService = $this->createMock(EditHistoryVisibilityService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new PostHistoryController( Application::APP_ID, $this->request, $this->postHistoryService, - $this->logger + $this->postMapper, + $this->permissionService, + $this->editHistoryVisibilityService, + $this->logger, + 'user1', ); } + private function setUpPermissionCheck(int $postId, string $authorId, int $categoryId, bool $canView): void { + $post = new Post(); + $post->setId($postId); + $post->setAuthorId($authorId); + $this->postMapper->method('find')->with($postId)->willReturn($post); + $this->permissionService->method('getCategoryIdFromPost')->with($postId)->willReturn($categoryId); + $this->editHistoryVisibilityService->method('canViewEditHistory') + ->with('user1', $authorId, $categoryId) + ->willReturn($canView); + } + public function testGetHistoryReturnsHistorySuccessfully(): void { $postId = 1; + $this->setUpPermissionCheck($postId, 'user1', 10, true); $historyData = [ 'current' => [ @@ -83,11 +112,19 @@ class PostHistoryControllerTest extends TestCase { $this->assertCount(2, $data['history']); } + public function testGetHistoryReturnsForbiddenWhenNotAllowed(): void { + $postId = 1; + $this->setUpPermissionCheck($postId, 'other_user', 10, false); + + $response = $this->controller->getHistory($postId); + + $this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + public function testGetHistoryReturnsNotFoundWhenPostDoesNotExist(): void { $postId = 999; - $this->postHistoryService->expects($this->once()) - ->method('getPostHistory') + $this->postMapper->method('find') ->with($postId) ->willThrowException(new DoesNotExistException('Post not found')); @@ -99,6 +136,7 @@ class PostHistoryControllerTest extends TestCase { public function testGetHistoryReturnsEmptyHistoryForNeverEditedPost(): void { $postId = 1; + $this->setUpPermissionCheck($postId, 'user1', 10, true); $historyData = [ 'current' => [ @@ -127,6 +165,7 @@ class PostHistoryControllerTest extends TestCase { public function testGetHistoryHandlesException(): void { $postId = 1; + $this->setUpPermissionCheck($postId, 'user1', 10, true); $this->postHistoryService->expects($this->once()) ->method('getPostHistory') @@ -145,6 +184,7 @@ class PostHistoryControllerTest extends TestCase { public function testGetHistoryShowsModeratorEdits(): void { $postId = 1; + $this->setUpPermissionCheck($postId, 'user1', 10, true); $historyData = [ 'current' => [ diff --git a/tests/unit/Service/AdminSettingsServiceTest.php b/tests/unit/Service/AdminSettingsServiceTest.php index dd48b58..2fbb8d3 100644 --- a/tests/unit/Service/AdminSettingsServiceTest.php +++ b/tests/unit/Service/AdminSettingsServiceTest.php @@ -49,12 +49,14 @@ class AdminSettingsServiceTest extends TestCase { }; }); - $this->config->expects($this->exactly(2)) + $this->config->expects($this->exactly(4)) ->method('getAppValueBool') ->willReturnCallback(function ($key, $default, $lazy) { return match ($key) { AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true, AdminSettingsService::SETTING_IS_INITIALIZED => false, + AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true, + AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false, default => $default, }; }); @@ -62,11 +64,13 @@ class AdminSettingsServiceTest extends TestCase { $result = $this->service->getAllSettings(); $this->assertIsArray($result); - $this->assertCount(4, $result); + $this->assertCount(6, $result); $this->assertEquals('My Forum', $result[AdminSettingsService::SETTING_TITLE]); $this->assertEquals('Welcome!', $result[AdminSettingsService::SETTING_SUBTITLE]); $this->assertTrue($result[AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS]); $this->assertFalse($result[AdminSettingsService::SETTING_IS_INITIALIZED]); + $this->assertTrue($result[AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY]); + $this->assertFalse($result[AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE]); } public function testGetSettingReturnsCorrectStringValue(): void { @@ -183,12 +187,14 @@ class AdminSettingsServiceTest extends TestCase { }; }); - $this->config->expects($this->exactly(2)) + $this->config->expects($this->exactly(4)) ->method('getAppValueBool') ->willReturnCallback(function ($key, $default, $lazy) { return match ($key) { AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => true, AdminSettingsService::SETTING_IS_INITIALIZED => false, + AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true, + AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false, default => $default, }; }); @@ -232,12 +238,14 @@ class AdminSettingsServiceTest extends TestCase { }; }); - $this->config->expects($this->exactly(2)) + $this->config->expects($this->exactly(4)) ->method('getAppValueBool') ->willReturnCallback(function ($key, $default, $lazy) { return match ($key) { AdminSettingsService::SETTING_ALLOW_GUEST_ACCESS => false, AdminSettingsService::SETTING_IS_INITIALIZED => false, + AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY => true, + AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE => false, default => $default, }; }); diff --git a/tests/unit/Service/EditHistoryVisibilityServiceTest.php b/tests/unit/Service/EditHistoryVisibilityServiceTest.php new file mode 100644 index 0000000..3f5592d --- /dev/null +++ b/tests/unit/Service/EditHistoryVisibilityServiceTest.php @@ -0,0 +1,172 @@ +adminSettingsService = $this->createMock(AdminSettingsService::class); + $this->userPreferencesService = $this->createMock(UserPreferencesService::class); + $this->permissionService = $this->createMock(PermissionService::class); + + $this->service = new EditHistoryVisibilityService( + $this->adminSettingsService, + $this->userPreferencesService, + $this->permissionService, + ); + } + + public function testOwnerCanAlwaysSeeOwnHistory(): void { + // No admin settings or permissions needed — owner always sees own history + $this->assertTrue( + $this->service->canViewEditHistory('user1', 'user1', self::CATEGORY_ID) + ); + } + + public function testModeratorCanAlwaysSeeHistory(): void { + $this->permissionService->method('hasCategoryPermission') + ->with('mod1', self::CATEGORY_ID, 'canModerate') + ->willReturn(true); + $this->permissionService->method('hasAdminOrModeratorRole') + ->willReturn(false); + + $this->assertTrue( + $this->service->canViewEditHistory('mod1', 'user1', self::CATEGORY_ID) + ); + } + + public function testAdminRoleCanAlwaysSeeHistory(): void { + $this->permissionService->method('hasCategoryPermission') + ->willReturn(false); + $this->permissionService->method('hasAdminOrModeratorRole') + ->with('admin1') + ->willReturn(true); + + $this->assertTrue( + $this->service->canViewEditHistory('admin1', 'user1', self::CATEGORY_ID) + ); + } + + public function testPublicEditHistoryOffBlocksOtherUsers(): void { + $this->permissionService->method('hasCategoryPermission')->willReturn(false); + $this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false); + + $this->adminSettingsService->method('getSetting') + ->with(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY) + ->willReturn(false); + + $this->assertFalse( + $this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID) + ); + } + + public function testPublicEditHistoryOnAllowsOtherUsers(): void { + $this->permissionService->method('hasCategoryPermission')->willReturn(false); + $this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false); + + $this->adminSettingsService->method('getSetting') + ->willReturnMap([ + [AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true], + [AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false], + ]); + + $this->assertTrue( + $this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID) + ); + } + + public function testUserOverrideHidesHistoryFromOthers(): void { + $this->permissionService->method('hasCategoryPermission')->willReturn(false); + $this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false); + + $this->adminSettingsService->method('getSetting') + ->willReturnMap([ + [AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true], + [AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, true], + ]); + + $this->userPreferencesService->method('getPreference') + ->with('author1', UserPreferencesService::PREF_HIDE_EDIT_HISTORY) + ->willReturn(true); + + $this->assertFalse( + $this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID) + ); + } + + public function testUserOverrideDoesNotAffectOwner(): void { + // Owner always sees own history regardless of override setting + $this->assertTrue( + $this->service->canViewEditHistory('author1', 'author1', self::CATEGORY_ID) + ); + } + + public function testUserOverrideDoesNotAffectModerator(): void { + $this->permissionService->method('hasCategoryPermission') + ->with('mod1', self::CATEGORY_ID, 'canModerate') + ->willReturn(true); + $this->permissionService->method('hasAdminOrModeratorRole') + ->willReturn(false); + + // Moderator can see history even when author has hidden it + $this->assertTrue( + $this->service->canViewEditHistory('mod1', 'author1', self::CATEGORY_ID) + ); + } + + public function testGuestCannotSeeHistoryWhenPublicOff(): void { + $this->adminSettingsService->method('getSetting') + ->with(AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY) + ->willReturn(false); + + $this->assertFalse( + $this->service->canViewEditHistory(null, 'author1', self::CATEGORY_ID) + ); + } + + public function testGuestCanSeeHistoryWhenPublicOn(): void { + $this->adminSettingsService->method('getSetting') + ->willReturnMap([ + [AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true], + [AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false], + ]); + + $this->assertTrue( + $this->service->canViewEditHistory(null, 'author1', self::CATEGORY_ID) + ); + } + + public function testUserOverrideDisabledDoesNotHideHistory(): void { + $this->permissionService->method('hasCategoryPermission')->willReturn(false); + $this->permissionService->method('hasAdminOrModeratorRole')->willReturn(false); + + $this->adminSettingsService->method('getSetting') + ->willReturnMap([ + [AdminSettingsService::SETTING_PUBLIC_EDIT_HISTORY, true], + [AdminSettingsService::SETTING_ALLOW_EDIT_HISTORY_USER_OVERRIDE, false], + ]); + + // Even if user has hide_edit_history pref set, when override is disabled, history is visible + $this->assertTrue( + $this->service->canViewEditHistory('viewer1', 'author1', self::CATEGORY_ID) + ); + } +} diff --git a/tests/unit/Service/UserPreferencesServiceTest.php b/tests/unit/Service/UserPreferencesServiceTest.php index 89611cc..e979b43 100644 --- a/tests/unit/Service/UserPreferencesServiceTest.php +++ b/tests/unit/Service/UserPreferencesServiceTest.php @@ -42,7 +42,7 @@ class UserPreferencesServiceTest extends TestCase { $userId = 'user1'; // Only config-based preferences (signature is from forum_users) - $this->config->expects($this->exactly(3)) + $this->config->expects($this->exactly(4)) ->method('getUserValue') ->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) { $this->assertEquals($userId, $uid); @@ -52,6 +52,7 @@ class UserPreferencesServiceTest extends TestCase { UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => 'true', UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => 'false', UserPreferencesService::PREF_UPLOAD_DIRECTORY => 'Forum', + UserPreferencesService::PREF_HIDE_EDIT_HISTORY => 'false', default => $default, }; }); @@ -59,11 +60,12 @@ class UserPreferencesServiceTest extends TestCase { $result = $this->service->getAllPreferences($userId); $this->assertIsArray($result); - $this->assertCount(4, $result); + $this->assertCount(5, $result); $this->assertTrue($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]); $this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS]); $this->assertEquals('Forum', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]); $this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]); + $this->assertFalse($result[UserPreferencesService::PREF_HIDE_EDIT_HISTORY]); } public function testGetPreferenceReturnsCorrectValue(): void { @@ -146,7 +148,7 @@ class UserPreferencesServiceTest extends TestCase { } }); - $this->config->expects($this->exactly(3)) + $this->config->expects($this->exactly(4)) ->method('getUserValue') ->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) { $this->assertEquals($userId, $uid); @@ -156,6 +158,7 @@ class UserPreferencesServiceTest extends TestCase { UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => 'false', UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => 'false', UserPreferencesService::PREF_UPLOAD_DIRECTORY => 'Documents', + UserPreferencesService::PREF_HIDE_EDIT_HISTORY => 'false', default => $default, }; }); @@ -163,11 +166,12 @@ class UserPreferencesServiceTest extends TestCase { $result = $this->service->updatePreferences($userId, $preferences); $this->assertIsArray($result); - $this->assertCount(4, $result); + $this->assertCount(5, $result); $this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]); $this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS]); $this->assertEquals('Documents', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]); $this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]); + $this->assertFalse($result[UserPreferencesService::PREF_HIDE_EDIT_HISTORY]); } public function testUpdatePreferencesThrowsExceptionForInvalidKey(): void {