diff --git a/includes/class-webdecoy-cloud-connect.php b/includes/class-webdecoy-cloud-connect.php index a8f57da..1ee4f74 100644 --- a/includes/class-webdecoy-cloud-connect.php +++ b/includes/class-webdecoy-cloud-connect.php @@ -48,6 +48,9 @@ class WebDecoy_Cloud_Connect private const ENTITLEMENTS_ENDPOINT = 'https://in.webdecoy.com/api/v1/sdk/entitlements'; /** Where the generic "Upgrade" link points once connected. */ + /** Where the app shows whether this site's sensor has actually reported. */ + private const SETUP_URL = 'https://app.webdecoy.com/onboarding/setup'; + private const BILLING_URL = 'https://app.webdecoy.com/billing'; /** WordPress-channel checkout entry point (contract ยง4). */ @@ -205,17 +208,55 @@ public function maybe_handle_return(): void $this->sync_entitlements(); $this->schedule_sync(); + // What just happened is that credentials were stored. Whether this site + // is covered is a separate fact, and it is not known yet: nothing has + // reported. Saying "cloud features are now active" here asserted + // coverage at the moment the keys landed, which is the same mistake as + // treating an OAuth redirect as proof of an install (#994). The link + // goes to the screen that watches for the first report. $org = (string) ($result['organization_name'] ?? ''); $this->set_notice( 'success', - $org !== '' - /* translators: %s: organization name */ - ? sprintf(__('Connected to WebDecoy Cloud (%s). Cloud features are now active.', 'webdecoy'), $org) - : __('Connected to WebDecoy Cloud. Cloud features are now active.', 'webdecoy') + self::connected_notice_message($org), + self::setup_url_from($result), + __('Watch for the first report', 'webdecoy') ); $this->redirect_clean(); } + /** + * The app page that watches THIS site for its first report. + * + * The exchange names the property the site became and hands back a + * setup URL scoped to it (app#994). Without the scope the page shows + * whichever site the app last had selected, and on an account with + * several sites the owner watched the wrong one. The server's URL is + * used only when it is where we would have sent them anyway: the app's + * own origin, the setup page. Anything else falls back to the unscoped + * page rather than sending an admin to an address a response chose. + * + * @param array $result Decoded exchange response. + */ + public static function setup_url_from(array $result): string + { + $candidate = isset($result['setup_url']) && is_string($result['setup_url']) ? trim($result['setup_url']) : ''; + if ($candidate === '') { + return self::SETUP_URL; + } + $parts = parse_url($candidate); + $base = parse_url(self::SETUP_URL); + if ( + !is_array($parts) || !is_array($base) + || ($parts['scheme'] ?? '') !== 'https' + || ($parts['host'] ?? '') !== ($base['host'] ?? '') + || ($parts['path'] ?? '') !== ($base['path'] ?? '') + || isset($parts['user']) || isset($parts['pass']) || isset($parts['port']) + ) { + return self::SETUP_URL; + } + return $candidate; + } + /** * Disconnect: clear credentials, org metadata, and cached entitlements * locally. No remote call is made (P0 contract). @@ -488,9 +529,36 @@ public static function plan_label(string $plan): string /** * Store a one-shot admin notice. */ - private function set_notice(string $type, string $message): void + /** + * What to say when the credentials have landed. + * + * Public and static so it can be asserted on without a WordPress runtime: + * the thing worth pinning is that it does not claim coverage. It said + * "Cloud features are now active" at the moment the keys were stored, + * which is a claim about this site being covered, made before anything + * from this site had been received (#994). + * + * @param string $org Organization name, empty when the server did not name one. + */ + public static function connected_notice_message(string $org): string + { + $tail = __('Your next page view sends the first report; until one arrives the cloud has nothing from this site.', 'webdecoy'); + + if ($org === '') { + return __('Connected to WebDecoy Cloud.', 'webdecoy') . ' ' . $tail; + } + + /* translators: %s: organization name */ + return sprintf(__('Connected to WebDecoy Cloud (%s).', 'webdecoy'), $org) . ' ' . $tail; + } + + private function set_notice(string $type, string $message, string $url = '', string $label = ''): void { - set_transient(self::NOTICE_TRANSIENT, ['type' => $type, 'message' => $message], MINUTE_IN_SECONDS); + set_transient( + self::NOTICE_TRANSIENT, + ['type' => $type, 'message' => $message, 'url' => $url, 'label' => $label], + MINUTE_IN_SECONDS + ); } /** @@ -505,11 +573,27 @@ public function render_notices(): void delete_transient(self::NOTICE_TRANSIENT); $class = ($notice['type'] ?? 'success') === 'error' ? 'notice-error' : 'notice-success'; + $url = (string) ($notice['url'] ?? ''); + $label = (string) ($notice['label'] ?? ''); + + // The link is built here rather than carried as markup: the message is + // escaped as text, and a notice that accepted HTML would be a place for + // one to arrive. + $link = ''; + if ($url !== '' && $label !== '') { + $link = sprintf( + ' %s', + esc_url($url), + esc_html($label) + ); + } + printf( - '

%s %s

', + '

%s %s%s

', esc_attr($class), esc_html__('WebDecoy Cloud:', 'webdecoy'), - esc_html((string) $notice['message']) + esc_html((string) $notice['message']), + $link // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built above from esc_url + esc_html ); } diff --git a/tests/CloudConnectTest.php b/tests/CloudConnectTest.php index 9cf1f3a..5fd2a7a 100644 --- a/tests/CloudConnectTest.php +++ b/tests/CloudConnectTest.php @@ -111,3 +111,65 @@ $same('Team Annual', WebDecoy_Cloud_Connect::plan_label('team_annual')); $same('Connected', WebDecoy_Cloud_Connect::plan_label(''), 'empty slug -> generic label'); }); + +echo "\nCloud Connect: what the success notice claims\n"; + +// Storing credentials is not evidence that this site is covered. The notice +// said "Cloud features are now active" the instant the keys landed, which +// asserts coverage before anything from the site has been received (#994). +$t('the connected notice does not claim the site is covered', function () use ($same, $true) { + foreach (['', 'Acme Ltd'] as $org) { + $msg = WebDecoy_Cloud_Connect::connected_notice_message($org); + + $true( + stripos($msg, 'first report') !== false, + 'says a report is still to come' + ); + foreach (['now active', 'are active', 'is protected', 'is now protected'] as $claim) { + $true( + stripos($msg, $claim) === false, + "does not claim coverage with \"{$claim}\"" + ); + } + } +}); + +$t('the connected notice names the organization when the server named one', function () use ($same, $true) { + $true( + strpos(WebDecoy_Cloud_Connect::connected_notice_message('Acme Ltd'), 'Acme Ltd') !== false, + 'the organization is named' + ); + $true( + strpos(WebDecoy_Cloud_Connect::connected_notice_message(''), '()') === false, + 'no empty parentheses when the server named none' + ); +}); + +echo "\nCloud Connect: where the first-report link goes\n"; + +// The exchange scopes the setup page to the property the site became +// (app#994). The unscoped page shows whichever site the app last had +// selected, which on an account with several sites is the wrong one. +$t('the first-report link is the server\'s property-scoped setup page', function () use ($same) { + $scoped = 'https://app.webdecoy.com/onboarding/setup?property=6aa166fa-763a-4b1c-b037-076befb7b53c'; + $same($scoped, WebDecoy_Cloud_Connect::setup_url_from(['setup_url' => $scoped])); +}); + +$t('without a server URL the link is the unscoped setup page', function () use ($same) { + $same('https://app.webdecoy.com/onboarding/setup', WebDecoy_Cloud_Connect::setup_url_from([])); + $same('https://app.webdecoy.com/onboarding/setup', WebDecoy_Cloud_Connect::setup_url_from(['setup_url' => ' '])); + $same('https://app.webdecoy.com/onboarding/setup', WebDecoy_Cloud_Connect::setup_url_from(['setup_url' => 42])); +}); + +$t('a server URL anywhere but the app\'s own setup page is not followed', function () use ($same) { + foreach ([ + 'https://evil.example.com/onboarding/setup?property=x', + 'http://app.webdecoy.com/onboarding/setup?property=x', + 'https://app.webdecoy.com/billing?property=x', + 'https://app.webdecoy.com:8443/onboarding/setup', + 'https://user:pw@app.webdecoy.com/onboarding/setup', + 'javascript:alert(1)', + ] as $bad) { + $same('https://app.webdecoy.com/onboarding/setup', WebDecoy_Cloud_Connect::setup_url_from(['setup_url' => $bad]), $bad); + } +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b737918..739a837 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -26,6 +26,23 @@ require_once $src . 'Filter/Evaluator.php'; require_once $src . 'FilterRule.php'; +/** + * WordPress's translation function, for helpers that are otherwise + * WordPress-free. + * + * A pass-through, which is what makes the assertions meaningful: the tests pin + * the English source strings, and every translation is derived from those. The + * guard keeps this inert inside a real WordPress runtime, where the real + * function is already defined and is the one that must be used. + */ +if (!function_exists('__')) { + function __(string $text, string $domain = 'default'): string // phpcs:ignore + { + unset($domain); + return $text; + } +} + final class TestRunner { /** @var int */