Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 92 additions & 8 deletions includes/class-webdecoy-cloud-connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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<string,mixed> $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).
Expand Down Expand Up @@ -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
);
}

/**
Expand All @@ -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(
' <a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
esc_url($url),
esc_html($label)
);
}

printf(
'<div class="notice %s is-dismissible"><p><strong>%s</strong> %s</p></div>',
'<div class="notice %s is-dismissible"><p><strong>%s</strong> %s%s</p></div>',
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
);
}

Expand Down
62 changes: 62 additions & 0 deletions tests/CloudConnectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
17 changes: 17 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading