diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index 04718cbe8..8333c343c 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -14,19 +14,75 @@ */ class Settings_Fields { + /** + * Instance of this class. + * + * @var Settings_Fields + */ + private static Settings_Fields $instance; + + /** + * The settings fields definitions. + * + * @var array> + */ + private array $fields; + + /** + * The default settings values. + * + * @var array> + */ + private array $defaults; + + /** + * Constructor. + * + * Initializes the settings fields and default values. + */ + public function __construct() { + $this->init_fields(); + $this->init_defaults(); + } + + /** + * Retrieve the instance of this class. + * + * @return Settings_Fields + */ + private static function get_instance(): Settings_Fields { + if ( ! isset( self::$instance ) ) { + self::$instance = new self(); + } + + return self::$instance; + } + /** * Retrieve the default setting values * * @return array> */ public static function get_default_values(): array { - static $defaults = []; + return self::get_instance()->defaults; + } - if ( ! empty( $defaults ) ) { - return $defaults; - } + /** + * Retrieve the settings fields. + * + * @return array> + */ + public static function get_field_definitions(): array { + return self::get_instance()->fields; + } - $defaults = [ + /** + * Initialize default settings values. + * + * @return void + */ + private function init_defaults() { + $this->defaults = [ 'general' => [ 'activate_by_default' => true, 'enable_tags' => true, @@ -60,23 +116,18 @@ public static function get_default_values(): array { ], ]; - $defaults = apply_filters( 'code_snippets_settings_defaults', $defaults ); - return $defaults; + $this->defaults = apply_filters( 'code_snippets_settings_defaults', $this->defaults ); } /** - * Retrieve the settings fields. + * Initialize the settings fields values. * - * @return array> + * @return void */ - public static function get_field_definitions(): array { - static $fields = []; - - if ( ! empty( $fields ) ) { - return $fields; - } + private function init_fields() { + $this->fields = []; - $fields['debug'] = [ + $this->fields['debug'] = [ 'database_update' => [ 'name' => __( 'Database Table Upgrade', 'code-snippets' ), 'type' => 'action', @@ -90,7 +141,7 @@ public static function get_field_definitions(): array { ], ]; - $fields['version-switch'] = [ + $this->fields['version-switch'] = [ 'version_switcher' => [ 'name' => __( 'Switch Version', 'code-snippets' ), 'type' => 'callback', @@ -108,7 +159,7 @@ public static function get_field_definitions(): array { ], ]; - $fields['general'] = [ + $this->fields['general'] = [ 'activate_by_default' => [ 'name' => __( 'Activate by Default', 'code-snippets' ), 'type' => 'checkbox', @@ -150,7 +201,7 @@ public static function get_field_definitions(): array { ]; if ( ! code_snippets()->licensing->is_licensed() ) { - $fields['general']['hide_upgrade_menu'] = [ + $this->fields['general']['hide_upgrade_menu'] = [ 'name' => __( 'Hide Upgrade Notices', 'code-snippets' ), 'type' => 'checkbox', 'label' => __( 'Hide notices inviting you to upgrade to Code Snippets Pro.', 'code-snippets' ), @@ -158,20 +209,20 @@ public static function get_field_definitions(): array { } if ( ! is_multisite() || is_main_site() ) { - $fields['general']['complete_uninstall'] = [ + $this->fields['general']['complete_uninstall'] = [ 'name' => __( 'Complete Uninstall', 'code-snippets' ), 'type' => 'checkbox', 'label' => __( 'When the plugin is deleted from the Plugins menu, also delete all snippets and plugin settings.', 'code-snippets' ), ]; } - $fields['general']['enable_admin_bar'] = [ + $this->fields['general']['enable_admin_bar'] = [ 'name' => __( 'Enable Admin Bar Menu', 'code-snippets' ), 'type' => 'checkbox', 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), ]; - $fields['general']['admin_bar_snippet_limit'] = [ + $this->fields['general']['admin_bar_snippet_limit'] = [ 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), 'type' => 'number', 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), @@ -185,14 +236,14 @@ public static function get_field_definitions(): array { ], ]; - $fields['general']['enable_feedback_reporter'] = [ + $this->fields['general']['enable_feedback_reporter'] = [ 'name' => __( 'Enable Feedback Reporter', 'code-snippets' ), 'type' => 'checkbox', 'label' => __( 'Show a button on Code Snippets pages for reporting bugs, requesting features and sending feedback.', 'code-snippets' ), 'desc' => __( 'Reports include your site address, contact details and a list of active plugins, so that the team can reproduce the problem.', 'code-snippets' ), ]; - $fields['editor'] = [ + $this->fields['editor'] = [ 'indent_with_tabs' => [ 'name' => __( 'Indent With Tabs', 'code-snippets' ), 'type' => 'checkbox', @@ -279,7 +330,6 @@ public static function get_field_definitions(): array { ], ]; - $fields = apply_filters( 'code_snippets_settings_fields', $fields ); - return $fields; + $this->fields = apply_filters( 'code_snippets_settings_fields', $this->fields ); } } diff --git a/tests/e2e/code-snippets-insights.spec.ts b/tests/e2e/code-snippets-insights.spec.ts new file mode 100644 index 000000000..9071fcb78 --- /dev/null +++ b/tests/e2e/code-snippets-insights.spec.ts @@ -0,0 +1,314 @@ +import { expect, test } from '@playwright/test' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { URLS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' + +const clearSnippets = async () => { + const php = ` + global $wpdb; + $tables = [ \\Code_Snippets\\code_snippets()->db->get_table_name( false ) ]; + + if ( is_multisite() ) { + $tables[] = \\Code_Snippets\\code_snippets()->db->get_table_name( true ); + } + + foreach ( $tables as $table ) { + $wpdb->query( "DELETE FROM {$table}" ); + } + ` + + await wpCli(['eval', php]) +} + +const clearInsightsChartViews = async () => { + await wpCli(['eval', "delete_option( 'code_snippets_insights_preferences' );"]) +} + +test.describe('Insights screen', () => { + test.beforeEach(async () => { + await clearSnippets() + await clearInsightsChartViews() + }) + + test.afterEach(async () => { + await clearSnippets() + await clearInsightsChartViews() + }) + + test('opens a zero-data dashboard from the upper toolbar', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.locator('.code-snippets-toolbar-upper').getByRole('link', { name: 'Insights', exact: true }).click() + const activationChart = page.locator('[data-insights-chart="activation"]') + const totalChart = page.locator('[data-insights-chart="total"]') + + await expect(page).toHaveURL(/page=code-snippets-insights/) + await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible() + await expect(page.locator('.insights-chart-card').first()).toHaveAttribute('data-insights-chart', 'total') + expect(await page.locator('[data-insights-chart]').evaluateAll(charts => + charts.map(chart => chart.getAttribute('data-insights-chart')))).toEqual( + ['total', 'type', 'activation', 'conditions', 'location', 'tags']) + await expect(totalChart.locator('.insights-number-chart-value')).toHaveText('0') + await expect(totalChart.locator('.insights-number-chart-label')).toHaveText('Total snippets') + await expect(totalChart.locator('.insights-chart-view-toggle')).toHaveCount(0) + await expect(totalChart.locator('.insights-bar-chart')).toHaveCount(0) + await expect(totalChart.locator('.insights-pie-chart')).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'Snippet type' })).toBeVisible() + await expect(page.getByText('PHP', { exact: true })).toBeVisible() + await expect(page.getByText('Conditions', { exact: true })).toBeVisible() + await expect(activationChart.locator('.insights-pie-chart.is-empty')).toBeVisible() + await expect(activationChart.locator('.insights-pie-chart-legend')).toContainText('Active') + await expect(activationChart.locator('.insights-pie-chart-legend')).toContainText('Inactive') + await expect(page.getByRole('link', { name: 'Create new Snippet' })).toHaveCount(0) + }) + + test('shows current snippet distributions', async ({ page }) => { + const conditionId = await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Active Conditions', + active: true, + type: 'cond' + }) + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Active PHP', + active: true, + conditionId, + type: 'php' + }) + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Inactive HTML', + active: false, + type: 'html' + }) + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Active CSS', + active: true, + type: 'css' + }) + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Inactive JavaScript', + active: false, + type: 'js' + }) + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + const activationPie = page.locator('[data-insights-chart="activation"] .insights-pie-chart') + const conditionsChart = page.locator('[data-insights-chart="conditions"]') + + await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible() + await expect(page.locator('[data-insights-chart="total"] .insights-number-chart-value')).toHaveText('5') + await expect(page.getByRole('heading', { name: 'Snippet type' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Activation status' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Condition usage' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Location' })).toBeVisible() + await expect(page.getByText('Conditions', { exact: true })).toBeVisible() + expect(await activationPie.evaluate(element => element.style.background)).toContain('60%') + await expect(conditionsChart).toHaveAttribute('data-view', 'pie') + + const conditionLegend = conditionsChart.locator('.insights-pie-chart-legend') + const withConditions = conditionLegend.locator('li').filter({ + hasText: /^Uses conditions/ + }) + const withoutConditions = conditionLegend.locator('li').filter({ + hasText: /^Does not use conditions/ + }) + + await expect(withConditions.locator('span')).toHaveText('Uses conditions') + await expect(withConditions.locator('strong')).toHaveText('1') + await expect(withoutConditions.locator('span')).toHaveText('Does not use conditions') + await expect(withoutConditions.locator('strong')).toHaveText('4') + }) + + test('switches used tags between bar and cloud views', async ({ page }) => { + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Shared and Alpha Tags', + active: true, + tags: ['Shared', 'Alpha'] + }) + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Shared Tag', + active: true, + tags: ['Shared'] + }) + + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + const tagsChart = page.locator('[data-insights-chart="tags"]') + + await expect(page.getByRole('heading', { name: 'Tags' })).toBeVisible() + await expect(tagsChart).toHaveAttribute('data-view', 'bar') + await expect(tagsChart.locator('.insights-bar-chart')).toContainText('Shared') + await expect(tagsChart.getByRole('button', { name: 'Tags cloud view' })).toBeVisible() + + const response = page.waitForResponse(request => + 'POST' === request.request().method() && request.url().includes('/preferences/insights-chart-views') + ) + await tagsChart.getByRole('button', { name: 'Tags cloud view' }).click() + await response + + await expect(tagsChart).toHaveAttribute('data-view', 'cloud') + const tagCloud = tagsChart.locator('.insights-tags-cloud') + const sharedTag = tagCloud.locator('li').filter({ hasText: /^Shared/ }) + const alphaTag = tagCloud.locator('li').filter({ hasText: /^Alpha/ }) + + await expect(sharedTag).toHaveText('Shared (2 snippets)') + await expect(alphaTag).toHaveText('Alpha (1 snippet)') + expect(await sharedTag.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))) + .toBeGreaterThan(await alphaTag.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))) + await expect(tagsChart.locator('.insights-bar-chart')).toHaveCount(0) + + await page.reload() + await expect(tagsChart).toHaveAttribute('data-view', 'cloud') + }) + + test('links chart entries to their filtered snippet lists', async ({ page, baseURL }) => { + await SnippetsTestHelper.createSnippetViaCli({ + name: 'Insights Tagged Snippet', + active: true, + tags: ['sample'] + }) + + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + + const manageUrl = (query: string) => new URL(`${URLS.SNIPPETS_ADMIN}${query}`, baseURL).toString() + + const typeChart = page.locator('[data-insights-chart="type"]') + const activationChart = page.locator('[data-insights-chart="activation"]') + const tagsChart = page.locator('[data-insights-chart="tags"]') + + for (const [label, type] of [ + ['PHP', 'php'], + ['HTML', 'html'], + ['CSS', 'css'], + ['JS', 'js'], + ['Conditions', 'cond'] + ]) { + await expect(typeChart.getByRole('link', { name: label })).toHaveAttribute( + 'href', manageUrl(`&subpage=snippets&type=${type}`)) + } + + for (const status of ['active', 'inactive']) { + await expect(activationChart.getByRole('link', { name: new RegExp(`^${status}$`, 'i') })).toHaveAttribute( + 'href', manageUrl(`&subpage=snippets&status=${status}`)) + } + + const tagLink = tagsChart.getByRole('link', { name: 'sample' }) + const textColor = await page.evaluate(() => { + const element = document.body.appendChild(document.createElement('span')) + element.style.color = 'var(--cs-color-text)' + const color = getComputedStyle(element).color + element.remove() + return color + }) + + await expect(tagLink).toHaveAttribute('href', manageUrl('&tag=sample')) + await expect(tagLink).toHaveCSS('color', textColor) + await expect(tagLink).toHaveCSS('text-decoration-line', 'none') + }) + + test('switches and restores each Insights chart view', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + + const typeChart = page.locator('[data-insights-chart="type"]') + const activationChart = page.locator('[data-insights-chart="activation"]') + const conditionsChart = page.locator('[data-insights-chart="conditions"]') + const locationChart = page.locator('[data-insights-chart="location"]') + + await expect(typeChart).toHaveAttribute('data-view', 'bar') + await expect(typeChart.locator('.insights-bar-chart')).toBeVisible() + await expect(activationChart).toHaveAttribute('data-view', 'pie') + await expect(activationChart.locator('.insights-pie-chart-legend')).toBeVisible() + await expect(conditionsChart).toHaveAttribute('data-view', 'pie') + await expect(conditionsChart.locator('.insights-pie-chart-legend')).toBeVisible() + await expect(locationChart).toHaveAttribute('data-view', 'bar') + + const switchView = async (chart: typeof typeChart, view: 'Pie' | 'Bar') => { + const response = page.waitForResponse(request => + 'POST' === request.request().method() && request.url().includes('/preferences/insights-chart-views') + ) + + await chart.getByRole('button', { name: 'Pie' === view ? 'Chart view' : 'List view' }).click() + await response + } + + await switchView(typeChart, 'Pie') + await expect(typeChart).toHaveAttribute('data-view', 'pie') + await expect(typeChart.locator('.insights-pie-chart')).toBeVisible() + await expect(typeChart.locator('.insights-pie-chart-legend')).toContainText('PHP') + + await switchView(activationChart, 'Bar') + await expect(activationChart).toHaveAttribute('data-view', 'bar') + await expect(activationChart.locator('.insights-bar-chart')).toContainText('Active') + await expect(activationChart.locator('.insights-bar-chart')).toContainText('Inactive') + + await switchView(conditionsChart, 'Bar') + await expect(conditionsChart).toHaveAttribute('data-view', 'bar') + await expect(conditionsChart.locator('.insights-bar-chart')).toContainText('Uses conditions') + await expect(conditionsChart.locator('.insights-bar-chart')).toContainText('Does not use conditions') + + await switchView(locationChart, 'Pie') + await expect(locationChart).toHaveAttribute('data-view', 'pie') + await expect(locationChart.locator('.insights-pie-chart-legend')).toHaveCount(1) + + await page.reload() + await expect(typeChart).toHaveAttribute('data-view', 'pie') + await expect(activationChart).toHaveAttribute('data-view', 'bar') + await expect(conditionsChart).toHaveAttribute('data-view', 'bar') + await expect(locationChart).toHaveAttribute('data-view', 'pie') + }) + + test('restores a chart view when saving the preference fails', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + const conditionsChart = page.locator('[data-insights-chart="conditions"]') + + await expect(conditionsChart).toHaveAttribute('data-view', 'pie') + + await page.route('**/preferences/insights-chart-views', async route => { + await route.fulfill({ status: 500, body: JSON.stringify({ message: 'Save failed' }) }) + }) + + await conditionsChart.getByRole('button', { name: 'List view' }).click() + + await expect(conditionsChart).toHaveAttribute('data-view', 'pie') + }) + + test('keeps the latest chart views when an earlier save fails', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-insights')) + const typeChart = page.locator('[data-insights-chart="type"]') + const activationChart = page.locator('[data-insights-chart="activation"]') + let rejectFirstRequest: (() => void) | undefined + let signalFirstRequest: () => void + const firstRequestStarted = new Promise(resolve => { + signalFirstRequest = resolve + }) + + await page.route('**/preferences/insights-chart-views', async route => { + const { views } = <{ views: { type: string, activation: string } }> route.request().postDataJSON() + + if ('pie' === views.type && 'pie' === views.activation) { + await new Promise(resolve => { + rejectFirstRequest = resolve + signalFirstRequest() + }) + await route.fulfill({ status: 500, body: JSON.stringify({ message: 'Save failed' }) }) + return + } + + await route.fulfill({ status: 200, body: JSON.stringify({ views }) }) + }) + + await typeChart.getByRole('button', { name: 'Chart view' }).click() + await firstRequestStarted + + const successfulResponse = page.waitForResponse(response => + 'POST' === response.request().method() && 200 === response.status() + ) + await activationChart.getByRole('button', { name: 'List view' }).click() + await successfulResponse + + if (undefined === rejectFirstRequest) { + throw new Error('The first chart preference request was not intercepted.') + } + + rejectFirstRequest() + + await expect(typeChart).toHaveAttribute('data-view', 'pie') + await expect(activationChart).toHaveAttribute('data-view', 'bar') + }) +}) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 26edf30bc..00b89b21d 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -97,6 +97,70 @@ test.describe('Code Snippets List Page Actions', () => { } }) + test('Card view surfaces snippet insights in the card corner', async ({ page }) => { + test.skip(!await SnippetsTestHelper.isProLicensed(), 'Snippet insights are available with a Pro licence.') + + await switchSnippetView(page, 'Card view') + + try { + const card = page.locator('.snippets-card-grid .code-snippets-card').filter({ hasText: snippetName }) + const secureStatus = card.locator('.snippet-card-meta .snippet-secure-status') + + await expect(card).toBeVisible() + await expect(secureStatus).toBeVisible() + await expect(secureStatus.locator('.tooltip-trigger')).toHaveAccessibleName('Secure snippet') + + // A clean PHP snippet exposes security in the metadata row. Performance + // or finding data remains beside it when available. + const trigger = card.locator('.snippet-card-meta .cs-insights-trigger') + + // The snippet name must survive alongside the status elements rather than + // being truncated away to nothing by a reserved gutter. + const nameWidth = await card.locator('.snippet-card-header h3').evaluate(el => el.clientWidth) + expect(nameWidth).toBeGreaterThan(100) + + if (await trigger.count()) { + // The corner insights control opens the same modal as the table's + // Insights column. + const modal = page.locator('.cs-insights-modal') + await trigger.click() + await expect(modal).toBeVisible() + + // Dismiss before cleanup: the modal's screen overlay swallows pointer + // events, so leaving it open makes the view switch below hang. + await page.keyboard.press('Escape') + await expect(modal).toHaveCount(0) + } + } finally { + await switchSnippetView(page, 'Table view').catch(() => undefined) + } + }) + + test('Card view omits the insights corner when a snippet has no data', async ({ page }) => { + const cssSnippetName = SnippetsTestHelper.makeUniqueSnippetName() + + await SnippetsTestHelper.createSnippetViaCli({ + name: cssSnippetName, + active: false, + type: 'css' + }) + await helper.navigateToSnippetsAdmin() + await switchSnippetView(page, 'Card view') + + try { + const card = page.locator('.snippets-card-grid .code-snippets-card').filter({ hasText: cssSnippetName }) + + await expect(card).toBeVisible() + + // The scanner only covers PHP, so there is nothing to show — and unlike + // the table column, the card renders no em-dash placeholder. + await expect(card.locator('.cs-insights-trigger')).toHaveCount(0) + } finally { + await switchSnippetView(page, 'Table view').catch(() => undefined) + await helper.cleanupSnippet(cssSnippetName) + } + }) + test('Can toggle snippet activation from list page', async ({ page }) => { const snippetRow = snippetRowByName(page, snippetName) diff --git a/tests/unit/REST_API/Cloud/Cloud_Snippets_REST_Controller_Test.php b/tests/unit/REST_API/Cloud/Cloud_Snippets_REST_Controller_Test.php new file mode 100644 index 000000000..329148ce8 --- /dev/null +++ b/tests/unit/REST_API/Cloud/Cloud_Snippets_REST_Controller_Test.php @@ -0,0 +1,376 @@ +requested_url = ''; + $this->rest_server = $wp_rest_server ?? null; + + delete_user_option( $this->get_user_id(), 'snippets_per_page' ); + + add_filter( 'pre_http_request', [ $this, 'mock_cloud_search_request' ], 10, 3 ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + global $wp_rest_server; + + remove_filter( 'pre_http_request', [ $this, 'mock_cloud_search_request' ] ); + delete_user_option( $this->get_user_id(), 'snippets_per_page' ); + + $wp_rest_server = $this->rest_server; + + parent::tear_down(); + } + + /** + * Mock the outbound cloud search request. + * + * @param mixed $preempt Existing preempted value. + * @param array $parsed_args Parsed HTTP request arguments. + * @param string $url Requested URL. + * + * @return mixed + */ + public function mock_cloud_search_request( $preempt, array $parsed_args, string $url ) { + if ( false !== strpos( $url, 'private/allsnippets' ) ) { + ++$this->codevault_request_count; + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'snippets' => [], + 'cloud_id_rev' => [], + 'meta' => [ + 'total' => 0, + 'total_pages' => 0, + 'page' => 1, + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + } + + if ( false === strpos( $url, 'public/search' ) && false === strpos( $url, 'public/featured' ) ) { + return $preempt; + } + + $this->requested_url = $url; + + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query_args ); + + $per_page = isset( $query_args['per_page'] ) ? (int) $query_args['per_page'] : self::DEFAULT_PER_PAGE; + $page = isset( $query_args['page'] ) ? (int) $query_args['page'] : 0; + $total_items = 12; + $total_pages = (int) ceil( $total_items / max( 1, $per_page ) ); + $items_to_return = min( $per_page, $total_items ); + + $snippets = []; + + for ( $index = 0; $index < $items_to_return; $index++ ) { + $snippets[] = [ + 'id' => ( $page * $per_page ) + $index + 1, + 'name' => 'Cloud Snippet ' . ( $index + 1 ), + 'description' => 'Test description', + 'code' => ' [], + 'scope' => 'global', + 'status' => 4, + 'codevault' => 'General', + 'vote_count' => '0', + 'updated' => '2026-03-10 12:00:00', + ]; + } + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'data' => $snippets, + 'meta' => [ + 'total' => $total_items, + 'total_pages' => $total_pages, + 'page' => $page + 1, + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + } + + /** + * Make a REST API request to the cloud endpoint. + * + * @param array $params Request params. + * @param string $route Optional route suffix. + * + * @return WP_REST_Response + */ + private function make_request( array $params, string $route = '' ): WP_REST_Response { + global $wp_rest_server; + + $wp_rest_server = null; + rest_get_server(); + + $request = new WP_REST_Request( 'GET', $this->endpoint . $route ); + $request->add_header( 'Access-Control', code_snippets()->cloud_connection->get_local_token() ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * The cloud REST endpoint uses the snippets Screen Options value when per_page is omitted. + * + * @return void + */ + public function test_get_items_uses_snippets_per_page_user_option(): void { + update_user_option( $this->get_user_id(), 'snippets_per_page', 7 ); + + $response = $this->make_request( + [ + 'query' => 'test', + 'page' => 2, + ] + ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '7', $query_args['per_page'] ?? null ); + $this->assertSame( '1', $query_args['page'] ?? null ); + } + + /** + * Screen Options values above the cloud API limit are capped before the request is sent. + * + * @return void + */ + public function test_get_items_caps_snippets_per_page_user_option_at_one_hundred(): void { + update_user_option( $this->get_user_id(), 'snippets_per_page', 250 ); + + $response = $this->make_request( + [ + 'query' => 'test', + 'page' => 2, + ] + ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '100', $query_args['per_page'] ?? null ); + $this->assertSame( '1', $query_args['page'] ?? null ); + } + + /** + * Explicit per_page requests override the snippets Screen Options value. + * + * @return void + */ + public function test_get_items_respects_explicit_per_page_request(): void { + update_user_option( $this->get_user_id(), 'snippets_per_page', 7 ); + + $response = $this->make_request( + [ + 'query' => 'test', + 'page' => 2, + 'per_page' => 3, + ] + ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '3', $query_args['per_page'] ?? null ); + $this->assertSame( '1', $query_args['page'] ?? null ); + } + + /** + * Featured snippets use the default cloud search page size. + * + * @return void + */ + public function test_get_featured_items_uses_default_cloud_search_page_size(): void { + $expected_per_page = Manage_Menu::get_cloud_search_per_page(); + + $response = $this->make_request( [], '/featured' ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( (string) $expected_per_page, $query_args['per_page'] ?? null ); + } + + /** + * Featured snippets honour the cloud search page-size filter. + * + * @return void + */ + public function test_get_featured_items_uses_filtered_cloud_search_page_size(): void { + $filter = static fn() => 6; + add_filter( 'code_snippets/cloud_search/per_page', $filter ); + + $this->assertSame( 6, Manage_Menu::get_cloud_search_per_page() ); + + $response = $this->make_request( [], '/featured' ); + + remove_filter( 'code_snippets/cloud_search/per_page', $filter ); + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '6', $query_args['per_page'] ?? null ); + } + + /** + * Cloud snippets already downloaded to this site are reported with their local ID. + * + * @return void + */ + public function test_get_items_reports_local_ids_for_downloaded_snippets(): void { + $local = save_snippet( new Snippet( [ 'name' => 'Downloaded snippet' ] ) ); + $local->cloud_id = 2; + save_snippet( $local ); + + $response = $this->make_request( [ 'query' => 'test' ] ); + $snippets = $response->get_data()['snippets'] ?? []; + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $snippets ); + + $local_ids = wp_list_pluck( $snippets, 'local_id', 'id' ); + + $this->assertSame( $local->id, $local_ids[2] ?? null ); + $this->assertArrayHasKey( 1, $local_ids ); + $this->assertNull( $local_ids[1] ); + } + + /** + * Featured snippets report local IDs in the same way as search results. + * + * @return void + */ + public function test_get_featured_items_reports_local_ids_for_downloaded_snippets(): void { + $local = save_snippet( new Snippet( [ 'name' => 'Downloaded featured snippet' ] ) ); + $local->cloud_id = 3; + save_snippet( $local ); + + $response = $this->make_request( [], '/featured' ); + $snippets = $response->get_data()['snippets'] ?? []; + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $local->id, wp_list_pluck( $snippets, 'local_id', 'id' )[3] ?? null ); + } + + /** + * The AI search method is forwarded to the cloud as s_method=ai. + */ + public function test_search_method_ai_is_forwarded_to_cloud(): void { + $response = $this->make_request( + [ + 'query' => 'make my site more secure', + 'searchMethod' => 'ai', + ] + ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'ai', $query_args['s_method'] ?? null ); + } + + /** + * With no method params, the search defaults to keyword matching (term). + */ + public function test_search_method_defaults_to_term(): void { + $this->make_request( [ 'query' => 'test' ] ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 'term', $query_args['s_method'] ?? null ); + } + + /** + * A codevault search takes precedence over an AI search method. + */ + public function test_codevault_takes_precedence_over_ai(): void { + $this->make_request( + [ + 'query' => 'general', + 'searchByCodevault' => true, + 'searchMethod' => 'ai', + ] + ); + + parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args ); + + $this->assertSame( 'codevault', $query_args['s_method'] ?? null ); + } +} diff --git a/tests/unit/REST_API/REST_API_Snippets_Permissions_Test.php b/tests/unit/REST_API/REST_API_Snippets_Permissions_Test.php new file mode 100644 index 000000000..1204f10d2 --- /dev/null +++ b/tests/unit/REST_API/REST_API_Snippets_Permissions_Test.php @@ -0,0 +1,507 @@ +user->create( [ 'role' => 'administrator' ] ); + self::$subsite_admin_id = $factory->user->create( [ 'role' => 'administrator' ] ); + self::$editor_id = $factory->user->create( [ 'role' => 'editor' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$super_admin_id ); + + $site_snippet = new Snippet( + [ + 'name' => 'Site Snippet Fixture', + 'desc' => 'Fixture snippet for permission tests.', + 'code' => "// site fixture\n", + 'scope' => 'global', + 'active' => false, + ] + ); + + $saved_site = save_snippet( $site_snippet ); + $this->assertInstanceOf( Snippet::class, $saved_site ); + $this->site_snippet_id = $saved_site->id; + + if ( is_multisite() ) { + $network_snippet = new Snippet( + [ + 'name' => 'Network Snippet Fixture', + 'desc' => 'Fixture snippet for permission tests (network).', + 'code' => "// network fixture\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ); + + $saved_network = save_snippet( $network_snippet ); + $this->assertInstanceOf( Snippet::class, $saved_network ); + $this->network_snippet_id = $saved_network->id; + } + } + + /** + * Dispatch a REST request and return the raw response object. + * + * @param string $method HTTP method. + * @param string $endpoint Endpoint path. + * @param array $params Request parameters. + * + * @return WP_REST_Response + */ + protected function dispatch( string $method, string $endpoint, array $params = [] ): WP_REST_Response { + $request = new WP_REST_Request( $method, $endpoint ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * Test that an editor (no snippets cap) is blocked on every endpoint, regardless of network flag. + */ + public function test_editor_is_always_blocked() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch( 'GET', "/$this->namespace/$this->base_route" ); + $this->assert_forbidden_or_unauthorised( $response ); + + $response = $this->dispatch( 'GET', "/$this->namespace/$this->base_route", [ 'network' => true ] ); + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that the schema route remains publicly accessible. + */ + public function test_schema_route_is_public() { + wp_set_current_user( 0 ); + + $response = $this->dispatch( 'GET', "/$this->namespace/$this->base_route/schema" ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that a site administrator can list site-scoped snippets. + */ + public function test_site_admin_can_list_site_snippets() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route", + [ 'network' => false ] + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that an omitted `network` param defaults to site-scoped and is allowed. + */ + public function test_site_admin_can_list_with_omitted_network_param() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( 'GET', "/$this->namespace/$this->base_route" ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that a site administrator without the network cap is blocked when `network=true`. + * + * This is the core vulnerability: forging `network=true` in the payload must not + * escalate a subsite admin to network-scoped operations. + */ + public function test_site_admin_is_blocked_from_network_scoped_list() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot read a specific network-scoped snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_network_scoped_get_item() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route/$this->network_snippet_id", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot create a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_creating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/$this->namespace/$this->base_route", + [ + 'name' => 'Forged Network Snippet', + 'code' => "// forged\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot update a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_updating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/$this->namespace/$this->base_route/$this->network_snippet_id", + [ + 'name' => 'Hijacked', + 'network' => true, + ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot delete a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_deleting_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'DELETE', + "/$this->namespace/$this->base_route/$this->network_snippet_id", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot activate a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_activating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/$this->namespace/$this->base_route/$this->network_snippet_id/activate", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot deactivate a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_deactivating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/$this->namespace/$this->base_route/$this->network_snippet_id/deactivate", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot export a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_exporting_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route/$this->network_snippet_id/export", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot restore a trashed network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_restoring_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/$this->namespace/$this->base_route/$this->network_snippet_id/restore", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that stringly-typed truthy values also trigger the network capability check. + * + * @dataProvider provide_truthy_network_values + * + * @param mixed $value Payload value for `network`. + */ + public function test_forged_truthy_string_values_are_blocked( $value ) { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route", + [ 'network' => $value ] + ); + + $this->assert_forbidden_or_unauthorised( + $response, + "Expected forged network=$value to be blocked." + ); + } + + /** + * Data provider for truthy `network` variants a client might send. + * + * @return array + */ + public function provide_truthy_network_values(): array { + return [ + 'boolean true' => [ true ], + 'string "true"' => [ 'true' ], + 'string "1"' => [ '1' ], + 'integer 1' => [ 1 ], + 'string "yes"' => [ 'yes' ], + 'string "on"' => [ 'on' ], + 'string "anything"' => [ 'anything' ], + ]; + } + + /** + * Test that falsy network values remain site-scoped and are allowed for site admins. + * + * @dataProvider provide_falsy_network_values + * + * @param mixed $value Payload value for `network`. + */ + public function test_site_admin_allowed_for_falsy_network_values( $value ) { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route", + [ 'network' => $value ] + ); + + $this->assertSame( + 200, + $response->get_status(), + "Expected network=$value to be treated as site-scoped and allowed." + ); + } + + /** + * Data provider for falsy `network` variants. + * + * @return array + */ + public function provide_falsy_network_values(): array { + return [ + 'boolean false' => [ false ], + 'string "false"' => [ 'false' ], + 'string "0"' => [ '0' ], + 'integer 0' => [ 0 ], + ]; + } + + /** + * Test that a super administrator with manage_network_options can perform network-scoped operations. + */ + public function test_super_admin_can_perform_network_scoped_operations() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$super_admin_id ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + + $response = $this->dispatch( + 'GET', + "/$this->namespace/$this->base_route/$this->network_snippet_id", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Assert that a REST response indicates an auth failure. + * + * WordPress returns 401 if no user is logged in, otherwise 403. + * + * @param WP_REST_Response $response Response under test. + * @param string $message Optional failure message. + */ + protected function assert_forbidden_or_unauthorised( WP_REST_Response $response, string $message = '' ) { + $status = $response->get_status(); + + $this->assertContains( + $status, + [ 401, 403 ], + $message + ? $message + : sprintf( 'Expected 401 or 403, got %d', $status ) + ); + } +}