diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png index 477bf92..11c2970 100644 Binary files a/.wordpress-org/screenshot-1.png and b/.wordpress-org/screenshot-1.png differ diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png index ccbc70d..7ccf2d6 100644 Binary files a/.wordpress-org/screenshot-2.png and b/.wordpress-org/screenshot-2.png differ diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png index 0fccdac..18bf4b9 100644 Binary files a/.wordpress-org/screenshot-3.png and b/.wordpress-org/screenshot-3.png differ diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png index 598ed1f..fc8c0a5 100644 Binary files a/.wordpress-org/screenshot-4.png and b/.wordpress-org/screenshot-4.png differ diff --git a/README.txt b/README.txt index dd60988..67de39c 100644 --- a/README.txt +++ b/README.txt @@ -29,7 +29,7 @@ Choose the server version under OWA > Settings. * The user name of logged-in users, if Track Named Users is on. * WordPress admin pages, if Track WP Admin Pages is on (1.x only). -Previews, the Customizer and, if Don't Track Logged-in Users is on, logged-in users are not tracked. +Previews, the Customizer and logged-in users are not tracked. Turn off Don't Track Logged-in Users (on by default) to track logged-in users. = Multisite = @@ -46,11 +46,11 @@ A cached page keeps the tracking tag it was rendered with. When a site's tag cha 1. Install and activate the plugin. 2. Go to OWA > Settings. -3. Enter your OWA server's URL as the OWA Endpoint, ending with `/`. +3. Enter your OWA server's URL as the OWA Endpoint. Saving checks that OWA answers there and stores the URL it answers at, after any redirect. Without `https://` or `http://`, https is tried first, then http. 4. Enter the credentials: * 1.x: the API Key (OWA: Settings > Users > Edit) and the Secret Auth Key (`OWA_AUTH_KEY` in the server's `owa-config.php`). * 2.x: select 2.x and enter an Application Password (OWA: My Preferences > Application Passwords). A read-only password is enough. -5. Save, then select the Observation Profile to track and save again. The plugin stores its site ID; in 1.x the field is labeled Website ID. +5. Save. Once OWA accepts the credentials, the Observation Profile list appears: select the one to track and save again. The plugin stores its site ID; in 1.x the field is labeled Website ID. On a multisite network, set up OWA 2.x once under Network Admin > OWA. diff --git a/js/lib/fields.js b/js/lib/fields.js index 67cc667..ef95b20 100644 --- a/js/lib/fields.js +++ b/js/lib/fields.js @@ -47,3 +47,12 @@ export function showTagSettingsLink( payload, values ) { return Boolean( payload.tagSettingsUrl ) && [ 'owaVersion', 'owaEndpoint', 'siteId' ].every( ( key ) => values[ key ] === payload.values[ key ] ); } + +/** + * The Website ID picker shows once OWA has returned its list with the saved + * credentials, and stays up while the list is fetched again after a save. A + * Website ID the network assigned is shown read-only whatever OWA says. + */ +export function showPicker( field, sites ) { + return Boolean( field.readonly || sites.loaded ); +} diff --git a/js/network/index.js b/js/network/index.js index e5865b5..8a685c3 100644 --- a/js/network/index.js +++ b/js/network/index.js @@ -162,7 +162,8 @@ function NetworkPage() { ...secretsPayload( { appPassword: password } ), }; - if ( payload.assignProfiles && assign ) { + // only what the table showed + if ( payload.profilesLoaded && payload.assignProfiles && assign ) { data.assignedSiteIds = mappingPayload( mapping ); data.enabledSites = switchesPayload( payload.blogs, switches ); } @@ -200,7 +201,7 @@ function NetworkPage() { __next40pxDefaultSize __nextHasNoMarginBottom label="OWA Endpoint" - help="The URL of your OWA 2.x server, ending with a / (e.g. https://owa.example.com/)." + help="The URL of your OWA 2.x server (e.g. https://owa.example.com/). Without https:// or http://, https is tried first." type="url" value={ endpoint } onChange={ setEndpoint } @@ -242,7 +243,7 @@ function NetworkPage() { onChange={ setAssign } /> - { payload.active && ( + { payload.active && payload.profilesLoaded && ( - + { showPicker( field, sites ) && ( + + ) } { sites.notice &&

{ sites.notice }

} { payload.network.note &&

{ payload.network.note }

} { showTagSettingsLink( payload, values ) && ( @@ -119,7 +121,7 @@ function SettingsPage() { const [ payload, setPayload ] = useState( null ); const [ values, setValues ] = useState( {} ); const [ secrets, setSecrets ] = useState( {} ); - const [ sites, setSites ] = useState( { choices: [], notice: '', loading: true } ); + const [ sites, setSites ] = useState( { choices: [], notice: '', loading: true, loaded: false } ); const [ errors, setErrors ] = useState( {} ); const [ notice, setNotice ] = useState( null ); const [ saving, setSaving ] = useState( false ); @@ -128,7 +130,7 @@ function SettingsPage() { setSites( ( current ) => ( { ...current, loading: true } ) ); apiFetch( { path: SITES } ) .then( ( result ) => setSites( { ...result, loading: false } ) ) - .catch( ( error ) => setSites( { choices: [], notice: error.message, loading: false } ) ); + .catch( ( error ) => setSites( { choices: [], notice: error.message, loading: false, loaded: false } ) ); }; const apply = ( result ) => { diff --git a/owa-plugin.php b/owa-plugin.php index 42ac040..5fb54b7 100644 --- a/owa-plugin.php +++ b/owa-plugin.php @@ -74,6 +74,9 @@ class owaWp_plugin extends module { // shown with the Website ID list: why it is empty or short var $sitesNotice = ''; + // OWA returned its list with the saved credentials + var $sitesLoaded = false; + // OWA 2.x TrackerBundle::SITE_ID_PATTERN: the IDs it publishes a bundle for const BUNDLE_SITE_ID_PATTERN = '/^[A-Za-z0-9_-]{1,64}$/D'; @@ -728,7 +731,8 @@ function setupNotices( $version = null ) { // where the network sets the Observation Profile, the site admin cannot, and the page says so $assigned = owaWp\network::isActive() && owaWp\network::options()['assignProfiles']; - if ( ! $assigned && ! $this->isOwaReadyToTrack( $version ) ) { + // the picker shows once the credentials are in + if ( ! $assigned && $this->isOwaSdkReady( $version ) && ! $this->isOwaReadyToTrack( $version ) ) { $notices[] = $version === 2 ? 'Open Web Analytics requires that you select an Observation Profile before tracking can begin.' @@ -895,7 +899,7 @@ public function registerOptions() { 'title' => 'OWA Endpoint', 'page_name' => 'owa-wordpress', 'section' => 'general', - 'description' => 'The URL of your OWA instance (i.e. http://www.mydomain.com/path/to/owa/). This should be the same as the OWA_PUBLIC_URL of your OWA server instance as defined in its owa-config.php file and MUST end with a "/".', + 'description' => 'The URL of your OWA instance (e.g. https://owa.example.com/path/to/owa/), the OWA_PUBLIC_URL in its owa-config.php. Saving checks that OWA answers there. Without https:// or http://, https is tried first.', 'label_for' => 'OWA Endpoint', 'length' => 70, 'endWithSlash' => true, @@ -970,7 +974,7 @@ public function registerOptions() { 'excludeLoggedInUsers' => array( - 'default_value' => false, + 'default_value' => true, 'field' => array( 'type' => 'boolean', 'title' => 'Don't Track Logged-in Users', @@ -1071,6 +1075,14 @@ function getSitesNotice() { return $this->sitesNotice; } + /** + * Whether OWA returned the last site list: the credentials work. + */ + function sitesLoaded() { + + return $this->sitesLoaded; + } + /** * Options for the Website ID picker, from OWA's GET /base/v1/sites. * @@ -1084,11 +1096,13 @@ function getSitesFromOwa( $options = [] ) { $sites = null; $this->sitesNotice = ''; + $this->sitesLoaded = false; if ( $this->isOwaSdkReady() && ! empty( $this->owaSdk ) ) { $sites_client = $this->owaSdk->createSites(); $sites = $sites_client->listSites(); + $this->sitesLoaded = is_array( $sites ); if ( ! is_array( $sites ) ) { diff --git a/src/network.php b/src/network.php index d0a873c..7d6c1ab 100644 --- a/src/network.php +++ b/src/network.php @@ -427,7 +427,7 @@ public static function sanitize( array $input, array $stored ) { if ( array_key_exists( 'owaEndpoint', $input ) ) { - $problem = rest::endpointProblem( (string) $input['owaEndpoint'] ); + [ $url, $problem ] = rest::normalizeEndpoint( (string) $input['owaEndpoint'], (string) ( $stored['owaEndpoint'] ?? '' ) ); if ( $problem ) { @@ -435,7 +435,7 @@ public static function sanitize( array $input, array $stored ) { } else { - $out['owaEndpoint'] = trim( (string) $input['owaEndpoint'] ); + $out['owaEndpoint'] = $url; } } @@ -547,10 +547,28 @@ public static function load( $plugin ) { } /** - * Stores the network settings. Invalid credentials store nothing. Saving - * while the network configures the Profiles stores each site's from - * assignedSiteIds, and a site not yet assigned one gets its exact match; - * the first such save fetches the Profiles if none are cached yet. + * @param array $before + * @param array $after + */ + public static function credentialsChanged( array $before, array $after ) { + + foreach ( [ 'owaEndpoint', 'appPassword' ] as $key ) { + + if ( (string) ( $before[ $key ] ?? '' ) !== (string) ( $after[ $key ] ?? '' ) ) { + + return true; + } + } + + return false; + } + + /** + * Stores the network settings. Invalid credentials store nothing. New + * credentials drop the cached Profiles, and a save in network mode with + * none cached fetches them. Saving while the network configures the + * Profiles stores each site's from assignedSiteIds, and a site not yet + * assigned one gets its exact match. * * @param array $input * @return array{0: array|null, 1: array, 2: string[]} what was stored (null: nothing), problems, messages @@ -574,10 +592,17 @@ public static function save( array $input, $plugin ) { // saving marks everything seen $options['newProfileIds'] = []; + // Profiles fetched with other credentials say nothing about these + if ( self::credentialsChanged( $stored, $options ) ) { + + $options['sites'] = []; + $options['sitesFetchedAt'] = 0; + } + // stored first: the fetch below uses these credentials update_site_option( self::OPTION, $options ); - if ( self::isActive() && ! empty( $options['assignProfiles'] ) && empty( $options['sites'] ) ) { + if ( self::isActive() && empty( $options['sites'] ) ) { $fetch = self::fetchProfiles( $plugin, $options ); diff --git a/src/rest.php b/src/rest.php index c408178..6fe55d0 100644 --- a/src/rest.php +++ b/src/rest.php @@ -22,6 +22,9 @@ class rest { // a 1.x API key, which an application password never looks like const LEGACY_KEY_PATTERN = '/^[a-f0-9]{32}$/D'; + // under an endpoint, the tracker OWA 1.14 and 2.x both publish + const TRACKER_PATH = 'public/base/dist/owa.tracker.js'; + public static function register( $plugin ) { add_action( 'rest_api_init', fn() => self::registerRoutes( $plugin ) ); @@ -201,7 +204,7 @@ public static function sanitizeSettings( array $schema, array $input, array $sto case 'url': - $error = self::endpointProblem( (string) $value ); + [ $url, $error ] = self::normalizeEndpoint( (string) $value, (string) ( $stored[ $key ] ?? '' ) ); if ( $error ) { @@ -209,7 +212,7 @@ public static function sanitizeSettings( array $schema, array $input, array $sto } else { - $out[ $key ] = trim( (string) $value ); + $out[ $key ] = $url; } break; @@ -258,28 +261,86 @@ public static function sanitizeSettings( array $schema, array $input, array $sto } /** - * Why an OWA endpoint cannot be used, or '' ('' itself is allowed). + * The OWA endpoint to store for what was entered: where OWA's tracker + * loads from, after any redirect, with a trailing slash. Without a + * scheme, https:// is tried and then http://. An endpoint equal to the + * one stored is kept without asking OWA, so a save while OWA is down + * still goes through. + * + * @param string $current the stored endpoint + * @return array{0: string, 1: string} the URL ('' clears it), and why it cannot be used ('' when it can) */ - public static function endpointProblem( $endpoint ) { + public static function normalizeEndpoint( $endpoint, $current = '' ) { $endpoint = trim( (string) $endpoint ); if ( $endpoint === '' ) { - return ''; + return [ '', '' ]; + } + + if ( preg_match( '#^[a-z][a-z0-9+.-]*://#i', $endpoint ) ) { + + $candidates = [ rtrim( $endpoint, '/' ) . '/' ]; + + if ( ! preg_match( '#^https?://#i', $candidates[0] ) || ! filter_var( $candidates[0], FILTER_VALIDATE_URL ) ) { + + return [ '', 'Not a valid http:// or https:// URL.' ]; + } + + } else { + + $candidates = [ 'https://' . rtrim( $endpoint, '/' ) . '/', 'http://' . rtrim( $endpoint, '/' ) . '/' ]; + + if ( ! filter_var( $candidates[0], FILTER_VALIDATE_URL ) ) { + + return [ '', 'Not a valid URL.' ]; + } + } + + if ( (string) $current !== '' && in_array( (string) $current, $candidates, true ) ) { + + return [ (string) $current, '' ]; + } + + foreach ( $candidates as $url ) { + + $found = self::owaAt( $url ); + + if ( $found !== '' ) { + + return [ $found, '' ]; + } } - if ( ! preg_match( '#^https?://#i', $endpoint ) || ! filter_var( $endpoint, FILTER_VALIDATE_URL ) ) { + return [ '', sprintf( 'No OWA server found at %s.', implode( ' or ', $candidates ) ) ]; + } + + /** + * The endpoint OWA's tracker (1.14 and 2.x both publish it there) loads + * from, starting at $url and following redirects, or '' when it does not + * load. + */ + private static function owaAt( $url ) { - return 'Not a valid http:// or https:// URL.'; + $response = wp_remote_get( $url . self::TRACKER_PATH, [ 'timeout' => 5, 'limit_response_size' => 1024 ] ); + + if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) !== 200 ) { + + return ''; } - if ( substr( $endpoint, -1 ) !== '/' ) { + $final = isset( $response['http_response'] ) && is_object( $response['http_response'] ) && method_exists( $response['http_response'], 'get_response_object' ) + ? (string) $response['http_response']->get_response_object()->url + : ''; + + // a redirect to somewhere other than the tracker leaves the URL as entered + if ( ! preg_match( '#^https?://#i', $final ) || substr( $final, -strlen( self::TRACKER_PATH ) ) !== self::TRACKER_PATH ) { - return 'The URL must end with a slash (e.g. https://owa.example.com/).'; + return $url; } - return ''; + return substr( $final, 0, -strlen( self::TRACKER_PATH ) ); } /** @@ -311,7 +372,8 @@ public static function sitesPayload( $plugin ) { $choices[] = [ 'value' => (string) $id, 'label' => $site['label'] ]; } - return [ 'choices' => $choices, 'notice' => $plugin->getSitesNotice() ]; + // the page shows the picker only once OWA has accepted the credentials + return [ 'choices' => $choices, 'notice' => $plugin->getSitesNotice(), 'loaded' => $plugin->sitesLoaded() ]; } /** @@ -384,6 +446,8 @@ public static function networkPayload() { 'assignProfiles' => $options['assignProfiles'], 'profiles' => $profiles, 'sitesFetchedAt' => $options['sitesFetchedAt'], + // the table shows once OWA has listed its Profiles for these credentials + 'profilesLoaded' => $options['sitesFetchedAt'] > 0, 'blogs' => $blogs, ]; } diff --git a/tests/NetworkTest.php b/tests/NetworkTest.php index fc2699f..0dcd980 100644 --- a/tests/NetworkTest.php +++ b/tests/NetworkTest.php @@ -315,11 +315,11 @@ public function testInvalidNetworkSettingsStoreNothing(): void { $this->networkMode(); $before = $this->network; - [ $saved, $errors ] = network::save( [ 'owaEndpoint' => 'https://owa.example.com', 'appPassword' => '0123456789abcdef0123456789abcdef' ], $this->networkPlugin() ); + [ $saved, $errors ] = network::save( [ 'owaEndpoint' => 'ftp://owa.example.com/', 'appPassword' => '0123456789abcdef0123456789abcdef' ], $this->networkPlugin() ); $this->assertNull( $saved ); $this->assertSame( $before, $this->network ); - $this->assertSame( 'The URL must end with a slash (e.g. https://owa.example.com/).', $errors['owaEndpoint'] ); + $this->assertSame( 'Not a valid http:// or https:// URL.', $errors['owaEndpoint'] ); $this->assertStringStartsWith( 'This looks like a 1.x API key. Network mode needs an OWA 2.x application password', $errors['appPassword'] ); } @@ -472,6 +472,67 @@ public function getLastSitesError(): string { return 'Could not resolve host'; } $this->assertSame( $before, $this->network, 'The cached Profiles stay.' ); } + public function testNewCredentialsFetchTheProfilesAgain(): void { + + $this->stubBlogs(); + $this->networkMode( [ 'sites' => [ 'OWA-old' => [ 'site_id' => 'OWA-old', 'domain' => 'https://old.example.com', 'property_name' => '' ] ], 'sitesFetchedAt' => 1792000000 ] ); + + $fetches = []; + network::save( [ 'appPassword' => strrev( self::TOKEN ) ], $this->networkPlugin( $fetches ) ); + + $this->assertCount( 1, $fetches, 'Fetched with the new password, though the network does not configure the Profiles.' ); + $this->assertSame( [ 'OWA-main', 'OWA-arch', 'OWA-www' ], array_keys( $this->network['sites'] ) ); + $this->assertTrue( rest::networkPayload()['profilesLoaded'] ); + } + + public function testProfilesFromOtherCredentialsAreNotShown(): void { + + $this->stubBlogs(); + $this->networkMode( [ 'sites' => [ 'OWA-old' => [ 'site_id' => 'OWA-old', 'domain' => 'https://old.example.com', 'property_name' => '' ] ], 'sitesFetchedAt' => 1792000000 ] ); + + $refused = new class { + public function listSitesWithNetworkCredentials(): ?array { return null; } + public function getLastSitesError(): string { return 'HTTP 401'; } + }; + + [ $saved, $errors ] = network::save( [ 'owaEndpoint' => 'https://owa2.example.com/' ], $refused ); + + $this->assertNotNull( $saved, 'The settings are stored.' ); + $this->assertSame( 'Could not load the list of Observation Profiles from OWA: HTTP 401.', $errors['fetch'] ); + $this->assertSame( [], $this->network['sites'] ); + $this->assertFalse( rest::networkPayload()['profilesLoaded'], 'No table until OWA lists the Profiles.' ); + } + + public function testTheNetworksEndpointIsStoredWhereOwaAnswers(): void { + + $this->stubBlogs(); + $this->networkMode(); + + Functions\when( 'wp_remote_get' )->alias( fn( $url ) => str_starts_with( $url, 'https://owa2.example.com/' ) + ? [ 'response' => [ 'code' => 200 ] ] + : new \WP_Error( 'http_request_failed', 'cURL error 7' ) ); + + network::save( [ 'owaEndpoint' => 'owa2.example.com' ], $this->networkPlugin() ); + $this->assertSame( 'https://owa2.example.com/', $this->network['owaEndpoint'] ); + + [ $saved, $errors ] = network::save( [ 'owaEndpoint' => 'https://owa3.example.com/' ], $this->networkPlugin() ); + $this->assertNull( $saved ); + $this->assertSame( 'No OWA server found at https://owa3.example.com/.', $errors['owaEndpoint'] ); + } + + public function testUnchangedCredentialsKeepTheCachedProfiles(): void { + + $this->stubBlogs(); + $this->networkMode( [ 'sites' => [ 'OWA-old' => [ 'site_id' => 'OWA-old', 'domain' => 'https://old.example.com', 'property_name' => '' ] ], 'sitesFetchedAt' => 1792000000 ] ); + + $fetches = []; + network::save( [ 'owaEndpoint' => 'https://owa.example.com/', 'appPassword' => null ], $this->networkPlugin( $fetches ) ); + + $this->assertSame( [], $fetches ); + $this->assertSame( [ 'OWA-old' ], array_keys( $this->network['sites'] ) ); + $this->assertTrue( rest::networkPayload()['profilesLoaded'] ); + } + public function testNothingIsFetchedOutsideNetworkMode(): void { $fetches = []; diff --git a/tests/PluginTest.php b/tests/PluginTest.php index fea576d..5eeb671 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -105,15 +105,25 @@ public function testSetupNoticesAreWordedForTheVersionAsked(): void { $plugin = $this->makePlugin( [ 'owaVersion' => '1' ] ); - $this->assertSame( [ - 'Enter your OWA Endpoint, API Key and Secret Auth Key to load the list of websites from OWA.', - 'Open Web Analytics requires that you select a Website ID before tracking can begin.', - ], $plugin->setupNotices() ); - - $this->assertSame( [ - 'Enter your OWA Endpoint and an Application Password to load the list of Observation Profiles from OWA.', - 'Open Web Analytics requires that you select an Observation Profile before tracking can begin.', - ], $plugin->setupNotices( 2 ) ); + $this->assertSame( [ 'Enter your OWA Endpoint, API Key and Secret Auth Key to load the list of websites from OWA.' ], $plugin->setupNotices() ); + $this->assertSame( [ 'Enter your OWA Endpoint and an Application Password to load the list of Observation Profiles from OWA.' ], $plugin->setupNotices( 2 ) ); + } + + public function testChoosingAWebsiteIdIsAskedForOnceTheCredentialsAreIn(): void { + + $plugin = $this->makePlugin( [ 'owaVersion' => '1', 'owaEndpoint' => 'https://owa.example.com/', 'apiKey' => 'key', 'authKey' => 'secret' ] ); + + $this->assertSame( [ 'Open Web Analytics requires that you select a Website ID before tracking can begin.' ], $plugin->setupNotices() ); + } + + public function testLoggedInUsersAreNotTrackedByDefault(): void { + + Functions\when( 'get_option' )->justReturn( false ); + + $plugin = new \owaWp_plugin(); + $plugin->reloadOptions(); + + $this->assertTrue( $plugin->getOption( 'excludeLoggedInUsers' ) ); } public function testSetupNoticesGoOnceTheSettingsAreSaved(): void { @@ -140,6 +150,6 @@ public function testTheSettingsScreenLeavesSetupNoticesToThePage(): void { $dashboard = ob_get_clean(); $this->assertSame( '

Broken.

', $settings ); - $this->assertStringContainsString( 'select a Website ID before tracking can begin', $dashboard ); + $this->assertStringContainsString( 'Enter your OWA Endpoint, API Key and Secret Auth Key', $dashboard ); } } diff --git a/tests/RestTest.php b/tests/RestTest.php index ef5cc25..6ea24ca 100644 --- a/tests/RestTest.php +++ b/tests/RestTest.php @@ -31,12 +31,104 @@ private function schema(): array { // ---- validation ------------------------------------------------------------- - public function testAnEndpointNeedsASchemeAndATrailingSlash(): void { + /** + * Answers the endpoint probe: the tracker loads under each endpoint in + * $answers, from the endpoint it maps to (itself, or where a redirect + * ends); anything else fails to connect. Returns the URLs requested. + * + * @param array $answers + */ + private function owaAnswering( array $answers ): \ArrayObject { + + $requested = new \ArrayObject(); + + Functions\when( 'wp_remote_get' )->alias( function ( $url ) use ( $answers, $requested ) { + $requested[] = $url; + foreach ( $answers as $endpoint => $final ) { + if ( $url === $endpoint . rest::TRACKER_PATH ) { + $http = new class( $final . rest::TRACKER_PATH ) { + public function __construct( private string $url ) {} + public function get_response_object(): object { return (object) [ 'url' => $this->url ]; } + }; + return [ 'response' => [ 'code' => 200 ], 'http_response' => $http ]; + } + } + return new \WP_Error( 'http_request_failed', 'cURL error 7: Failed to connect' ); + } ); + + return $requested; + } + + public function testAnEndpointIsStoredWhereOwaAnswers(): void { + + $requested = $this->owaAnswering( [ 'https://owa.example.com/owa/' => 'https://owa.example.com/owa/' ] ); + + $this->assertSame( [ 'https://owa.example.com/owa/', '' ], rest::normalizeEndpoint( ' https://owa.example.com/owa ' ), 'With its trailing slash.' ); + $this->assertSame( [ 'https://owa.example.com/owa/public/base/dist/owa.tracker.js' ], $requested->getArrayCopy() ); + $this->assertSame( [ '', '' ], rest::normalizeEndpoint( '' ), 'Empty clears it.' ); + } + + public function testARedirectIsFollowedAndWhereItEndsIsStored(): void { + + $this->owaAnswering( [ 'http://owa.example.com/' => 'https://owa.example.com/' ] ); - $this->assertSame( 'Not a valid http:// or https:// URL.', rest::endpointProblem( 'owa.example.com/' ) ); - $this->assertSame( 'The URL must end with a slash (e.g. https://owa.example.com/).', rest::endpointProblem( 'https://owa.example.com/owa' ) ); - $this->assertSame( '', rest::endpointProblem( 'https://owa.example.com/owa/' ) ); - $this->assertSame( '', rest::endpointProblem( '' ), 'Empty clears it.' ); + $this->assertSame( [ 'https://owa.example.com/', '' ], rest::normalizeEndpoint( 'http://owa.example.com' ) ); + } + + public function testAnUnchangedEndpointIsNotAskedAgain(): void { + + $requested = $this->owaAnswering( [] ); + + $this->assertSame( [ 'https://owa.example.com/', '' ], rest::normalizeEndpoint( 'https://owa.example.com', 'https://owa.example.com/' ), 'OWA is down, the save goes through.' ); + $this->assertSame( [ 'http://owa.example.com/', '' ], rest::normalizeEndpoint( 'owa.example.com', 'http://owa.example.com/' ) ); + $this->assertCount( 0, $requested ); + } + + public function testAnEndpointWithoutASchemeTriesHttpsThenHttp(): void { + + $requested = $this->owaAnswering( [ 'http://owa.example.com:8080/owa/' => 'http://owa.example.com:8080/owa/' ] ); + + $this->assertSame( [ 'http://owa.example.com:8080/owa/', '' ], rest::normalizeEndpoint( 'owa.example.com:8080/owa' ) ); + $this->assertSame( [ + 'https://owa.example.com:8080/owa/public/base/dist/owa.tracker.js', + 'http://owa.example.com:8080/owa/public/base/dist/owa.tracker.js', + ], $requested->getArrayCopy() ); + } + + public function testHttpsIsKeptWhenItServesOwa(): void { + + $requested = $this->owaAnswering( [ 'https://owa.example.com/' => 'https://owa.example.com/', 'http://owa.example.com/' => 'http://owa.example.com/' ] ); + + $this->assertSame( [ 'https://owa.example.com/', '' ], rest::normalizeEndpoint( 'owa.example.com/' ) ); + $this->assertCount( 1, $requested, 'http:// is not tried.' ); + } + + public function testATypedSchemeIsNotSwapped(): void { + + $requested = $this->owaAnswering( [ 'http://owa.example.com/' => 'http://owa.example.com/' ] ); + + $this->assertSame( [ '', 'No OWA server found at https://owa.example.com/.' ], rest::normalizeEndpoint( 'https://owa.example.com/' ), 'https:// is never downgraded.' ); + $this->assertCount( 1, $requested ); + } + + public function testAnEndpointThatServesNoOwaIsRefused(): void { + + $this->owaAnswering( [] ); + + $this->assertSame( [ '', 'No OWA server found at https://owa.example.com/ or http://owa.example.com/.' ], rest::normalizeEndpoint( 'owa.example.com' ) ); + $this->assertSame( [ '', 'Not a valid http:// or https:// URL.' ], rest::normalizeEndpoint( 'ftp://owa.example.com/' ) ); + $this->assertSame( [ '', 'Not a valid URL.' ], rest::normalizeEndpoint( 'owa example com' ) ); + } + + public function testTheEndpointFoundIsStored(): void { + + $this->owaAnswering( [ 'https://owa.example.com/' => 'https://owa.example.com/' ] ); + + $result = rest::saveSettings( new \owaWp_plugin(), [ 'owaEndpoint' => 'owa.example.com' ] ); + + $this->assertTrue( $result['saved'] ); + $this->assertSame( 'https://owa.example.com/', $this->stored['owaEndpoint'] ); + $this->assertSame( 'https://owa.example.com/', $result['values']['owaEndpoint'], 'The page shows what was stored.' ); } public function testAnApplicationPasswordIs44LettersAndDigits(): void { @@ -65,7 +157,7 @@ public function testEachProblemIsReturnedUnderItsField(): void { [ , $errors ] = rest::sanitizeSettings( $this->schema(), [ 'owaVersion' => '3', - 'owaEndpoint' => 'https://owa.example.com', + 'owaEndpoint' => 'ftp://owa.example.com/', 'appPassword' => 'not-a-token', ], [] ); @@ -99,7 +191,7 @@ public function testAnInvalidSaveStoresNothing(): void { $this->stored = [ 'owaEndpoint' => 'https://owa.example.com/', 'enable' => 1 ]; - $result = rest::saveSettings( new \owaWp_plugin(), [ 'enable' => false, 'owaEndpoint' => 'owa.example.com' ] ); + $result = rest::saveSettings( new \owaWp_plugin(), [ 'enable' => false, 'owaEndpoint' => 'ftp://owa.example.com/' ] ); $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 400, $result->get_error_data()['status'] ); @@ -194,13 +286,15 @@ public function testThePageIsGivenEachFieldsVersionAndTab(): void { public function testTheSitesRouteListsWhatOwaReturnedAndWhy(): void { - $plugin = $this->createPartialMock( \owaWp_plugin::class, [ 'getSitesFromOwa', 'getSitesNotice' ] ); + $plugin = $this->createPartialMock( \owaWp_plugin::class, [ 'getSitesFromOwa', 'getSitesNotice', 'sitesLoaded' ] ); $plugin->method( 'getSitesFromOwa' )->willReturn( [ 'saved1' => [ 'label' => 'saved1 (saved; not in the list from OWA)', 'siteId' => 'saved1' ] ] ); $plugin->method( 'getSitesNotice' )->willReturn( 'Could not load the list of websites from OWA: HTTP 403' ); + $plugin->method( 'sitesLoaded' )->willReturn( false ); $this->assertSame( [ 'choices' => [ [ 'value' => 'saved1', 'label' => 'saved1 (saved; not in the list from OWA)' ] ], 'notice' => 'Could not load the list of websites from OWA: HTTP 403', + 'loaded' => false, ], rest::sitesPayload( $plugin ) ); } diff --git a/tests/SitePickerTest.php b/tests/SitePickerTest.php index 9b506ae..179c795 100644 --- a/tests/SitePickerTest.php +++ b/tests/SitePickerTest.php @@ -47,6 +47,7 @@ public function testReadsTheFlatRowsOwa114Returns(): void { 'aaa' => [ 'label' => 'aaa – https://one.example.com', 'siteId' => 'aaa' ], 'bbb' => [ 'label' => 'bbb – https://two.example.com', 'siteId' => 'bbb' ], ], $plugin->getSitesFromOwa() ); + $this->assertTrue( $plugin->sitesLoaded(), 'OWA accepted the credentials.' ); } public function testStillReadsTheNestedRowsOfServersBefore1101(): void { @@ -69,6 +70,15 @@ public function testFailedListingKeepsTheSavedSiteAndSaysWhy(): void { $this->assertSame( [ 'saved1' ], array_keys( $list ), 'Saving the form must not clear the site ID.' ); $this->assertSame( 'Could not load the list of websites from OWA: HTTP 403', $plugin->getSitesNotice() ); + $this->assertFalse( $plugin->sitesLoaded(), 'The page hides the picker.' ); + } + + public function testWithoutCredentialsNothingIsLoaded(): void { + + $plugin = $this->makePlugin( [ 'owaEndpoint' => 'https://owa.example.com/', 'siteId' => 'saved1' ] ); + + $this->assertSame( [ 'saved1' ], array_keys( $plugin->getSitesFromOwa() ) ); + $this->assertFalse( $plugin->sitesLoaded() ); } public function testSavedSiteMissingFromOwaIsStillOffered(): void { diff --git a/tests/TestCase.php b/tests/TestCase.php index 56e45d3..f3d6c13 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -36,6 +36,10 @@ protected function setUp(): void { 'is_customize_preview' => false, 'is_multisite' => false, 'get_current_screen' => fn() => null, + // an OWA server at every endpoint: saving one asks for its tracker + 'wp_remote_get' => fn() => [ 'response' => [ 'code' => 200 ] ], + 'is_wp_error' => fn( $thing ) => $thing instanceof \WP_Error, + 'wp_remote_retrieve_response_code' => fn( $response ) => $response['response']['code'] ?? '', ] ); require_once dirname( __DIR__ ) . '/owa-plugin.php'; diff --git a/tests/e2e/single.spec.js b/tests/e2e/single.spec.js index 3c9e745..429abf5 100644 --- a/tests/e2e/single.spec.js +++ b/tests/e2e/single.spec.js @@ -17,16 +17,18 @@ test( 'switching to 2.x shows its fields, saves, and tags pages with the bundle' await expect( field( '1.x' ) ).toBeChecked(); await expect( field( 'API Key' ) ).toBeVisible(); await expect( field( 'Application Password' ) ).toHaveCount( 0 ); - await expect( field( 'Website ID' ) ).toBeVisible(); + // no picker until OWA accepts the credentials, and so no request to choose from it + await expect( field( 'Website ID' ) ).toHaveCount( 0 ); // what is left to set up, worded for 1.x, shown by the page and not again by WordPress await expect( notice( page, 'Enter your OWA Endpoint, API Key and Secret Auth Key to load the list of websites from OWA.' ) ).toBeVisible(); - await expect( notice( page, 'Open Web Analytics requires that you select a Website ID before tracking can begin.' ) ).toBeVisible(); + await expect( notice( page, 'before tracking can begin' ) ).toHaveCount( 0 ); await expect( page.locator( 'div.notice-warning' ) ).toHaveCount( 0 ); await shot( page, testInfo, '01-settings-1x' ); await docShot( page, 1 ); await tab( 'Tracking' ).click(); await expect( field( 'Track Clicks' ) ).toBeVisible(); + await expect( field( "Don't Track Logged-in Users" ) ).toBeChecked(); await shot( page, testInfo, '02-tracking-tab-1x' ); await tab( 'General' ).click(); @@ -35,12 +37,9 @@ test( 'switching to 2.x shows its fields, saves, and tags pages with the bundle' await expect( field( 'API Key' ) ).toHaveCount( 0 ); await expect( field( 'Application Password' ) ).toBeVisible(); await expect( tab( 'Advanced' ) ).toHaveCount( 0 ); - // the same field is the Observation Profile in 2.x - await expect( field( 'Website ID' ) ).toHaveCount( 0 ); - await expect( field( 'Observation Profile' ).locator( 'option:checked' ) ).toHaveText( /^(Loading|Select Profile)…$/ ); + await expect( field( 'Observation Profile' ) ).toHaveCount( 0 ); // the notices switch to 2.x wording with it await expect( notice( page, 'Enter your OWA Endpoint and an Application Password to load the list of Observation Profiles from OWA.' ) ).toBeVisible(); - await expect( notice( page, 'Open Web Analytics requires that you select an Observation Profile before tracking can begin.' ) ).toBeVisible(); await expect( notice( page, 'API Key and Secret Auth Key' ) ).toHaveCount( 0 ); await shot( page, testInfo, '03-switched-to-2x' ); @@ -59,9 +58,20 @@ test( 'switching to 2.x shows its fields, saves, and tags pages with the bundle' await expect( notice( page, 'The settings were not saved.' ) ).toBeVisible(); await shot( page, testInfo, '05-invalid-password' ); + // a well-formed password OWA does not know is saved, and the picker stays hidden + await field( 'Application Password' ).fill( 'A'.repeat( 44 ) ); + await save.click(); + await expect( notice( page, 'Settings saved.' ) ).toBeVisible(); + await expect( page.getByText( 'Could not load the list of Observation Profiles from OWA' ) ).toBeVisible(); + await expect( field( 'Observation Profile' ) ).toHaveCount( 0 ); + + // the endpoint without its scheme or slash: OWA is found over http:// and that is stored + const bare = env( 'OWA_URL' ).replace( /^https?:\/\//, '' ).replace( /\/$/, '' ); + await field( 'OWA Endpoint' ).fill( bare ); await field( 'Application Password' ).fill( env( 'OWA_APP_PASSWORD' ) ); await save.click(); await expect( notice( page, 'Settings saved.' ) ).toBeVisible(); + await expect( field( 'OWA Endpoint' ) ).toHaveValue( env( 'OWA_URL' ) ); await expect( page.getByText( 'Saved. Enter a new one to replace it.' ) ).toBeVisible(); // saved credentials: that notice goes without a reload; choosing an Observation Profile is left await expect( notice( page, 'to load the list of Observation Profiles from OWA' ) ).toHaveCount( 0 ); diff --git a/tests/js/fields.test.js b/tests/js/fields.test.js index 5f7bfdf..4a7385d 100644 --- a/tests/js/fields.test.js +++ b/tests/js/fields.test.js @@ -1,5 +1,5 @@ import { test, expect } from 'vitest'; -import { fieldText, isVisible, setupNotices, showTagSettingsLink, visibleFields, visibleSections } from '../../js/lib/fields'; +import { fieldText, isVisible, setupNotices, showPicker, showTagSettingsLink, visibleFields, visibleSections } from '../../js/lib/fields'; const FIELDS = [ { key: 'owaVersion', section: 'general', version: null }, @@ -57,3 +57,13 @@ test( 'the setup notices follow the version chosen on the page, and go while tra expect( setupNotices( payload, { enable: true, owaVersion: '2' } ) ).toEqual( [ 'Enter an Application Password.' ] ); expect( setupNotices( payload, { enable: false, owaVersion: '2' } ) ).toEqual( [] ); } ); + +test( 'the Website ID picker shows once OWA has returned its list', () => { + const field = { key: 'siteId', readonly: false }; + + expect( showPicker( field, { loading: true, loaded: false } ) ).toBe( false ); + expect( showPicker( field, { loading: false, loaded: false } ) ).toBe( false ); + expect( showPicker( field, { loading: false, loaded: true } ) ).toBe( true ); + expect( showPicker( field, { loading: true, loaded: true } ) ).toBe( true ); + expect( showPicker( { ...field, readonly: true }, { loading: false, loaded: false } ) ).toBe( true ); +} );