From 081200d56f0562c856fa70f1c601f78100d25aa1 Mon Sep 17 00:00:00 2001
From: Chris Portscheller
Date: Thu, 23 Jul 2026 09:59:45 -0500
Subject: [PATCH 1/4] feat: gate IP enrichment behind entitlement + WordPress
Pro checkout URL
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add WebDecoy_Cloud_Connect::wp_upgrade_url() building the WordPress-channel
checkout link (app.webdecoy.com/billing/wordpress?organization_id=..&plan=wp_pro,
contract §4); Cloud tab Upgrade button now uses it.
- Gate WebDecoy_IP_Enrichment remote calls behind features.enrichment: fail
silently to no-enrichment when a synced entitlements cache says unentitled;
never-synced (manual-key) installs still defer to the server's own 403.
---
admin/partials/settings-page.php | 2 +-
includes/class-webdecoy-cloud-connect.php | 39 +++++++++++++++++++++--
includes/class-webdecoy-ip-enrichment.php | 15 +++++++++
3 files changed, 53 insertions(+), 3 deletions(-)
diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php
index 54f2fc2..9e07b17 100644
--- a/admin/partials/settings-page.php
+++ b/admin/partials/settings-page.php
@@ -756,7 +756,7 @@
-
+
+
+
+
+
+ —
+
+
+
+
+
+ 0) : ?>
+
+
+
+
+ 0) : ?>
+
+
+
+
+
+
+
+
+
+
+
+ 0) : ?>
+
+
+
+
+
+
+
+
+ |
|
@@ -359,7 +433,7 @@ class="button button-small">
- |
+ |
From 1de058386f0d0d060cfe3d091cf1dc28fc82ab93 Mon Sep 17 00:00:00 2001
From: Chris Portscheller
Date: Thu, 23 Jul 2026 10:00:18 -0500
Subject: [PATCH 4/4] test: pure-logic coverage for actor feed, intel, and
critical-moment
Dependency-free tests (php tests/run.php) for the WordPress-free helpers:
- feed pagination-continue, page normalization, cross-page IP merge, allowlist
exclusion, and cap/eviction ordering;
- intel response normalization incl. the free-connected redaction guarantee and
unique/validated/capped IP batching;
- the 7-day notice throttle, days-since-first-seen, and copy-variant selection.
---
tests/ActorFeedTest.php | 131 +++++++++++++++++++++++++++++++++++
tests/ActorIntelTest.php | 108 +++++++++++++++++++++++++++++
tests/CriticalMomentTest.php | 58 ++++++++++++++++
3 files changed, 297 insertions(+)
create mode 100644 tests/ActorFeedTest.php
create mode 100644 tests/ActorIntelTest.php
create mode 100644 tests/CriticalMomentTest.php
diff --git a/tests/ActorFeedTest.php b/tests/ActorFeedTest.php
new file mode 100644
index 0000000..88eb6b2
--- /dev/null
+++ b/tests/ActorFeedTest.php
@@ -0,0 +1,131 @@
+ [
+ ['id' => 'a1', 'ips' => ['1.1.1.1', '2.2.2.2'], 'last_seen' => 100],
+ ['id' => '', 'ips' => ['3.3.3.3']], // no id -> dropped
+ ['id' => 'a3', 'ips' => []], // no ips -> dropped
+ ['id' => 'a4', 'ips' => ['4.4.4.4', 5, ' ', '6.6.6.6']], // non-string/blank ip filtered
+ 'garbage', // non-array -> skipped
+ ],
+ 'next_since' => 555,
+ 'complete' => false,
+ ]);
+
+ $same(2, count($page['actors']), 'only well-formed actors survive');
+ $same('a1', $page['actors'][0]['id']);
+ $same(['1.1.1.1', '2.2.2.2'], $page['actors'][0]['ips']);
+ $same(['4.4.4.4', '6.6.6.6'], $page['actors'][1]['ips'], 'non-string and blank ips removed');
+ $same(555, $page['next_since']);
+ $true($page['complete'] === false, 'complete is a strict bool');
+});
+
+$t('missing pagination fields default safely', function () use ($same, $true) {
+ $page = WebDecoy_Actor_Feed::normalize_feed_page([]);
+ $same([], $page['actors']);
+ $same(0, $page['next_since'], 'absent next_since -> 0');
+ $true($page['complete'] === false, 'absent complete -> false');
+});
+
+echo "\nActor Feed: cross-page IP merge\n";
+
+$t('merges ips across actors, keeping the most-recent last_seen', function () use ($same) {
+ $acc = WebDecoy_Actor_Feed::merge_actor_ips([], [
+ ['id' => 'a1', 'ips' => ['1.1.1.1'], 'last_seen' => 100],
+ ['id' => 'a2', 'ips' => ['2.2.2.2'], 'last_seen' => 200],
+ ]);
+ // Second page: 1.1.1.1 reappears with a newer last_seen under a different actor.
+ $acc = WebDecoy_Actor_Feed::merge_actor_ips($acc, [
+ ['id' => 'a9', 'ips' => ['1.1.1.1'], 'last_seen' => 500],
+ ]);
+
+ $same(2, count($acc), 'ip set is deduped');
+ $same('a9', $acc['1.1.1.1']['actor_id'], 'newer last_seen wins the actor attribution');
+ $same(500, $acc['1.1.1.1']['last_seen']);
+ $same(200, $acc['2.2.2.2']['last_seen']);
+});
+
+$t('an older repeat does not overwrite a newer record', function () use ($same) {
+ $acc = WebDecoy_Actor_Feed::merge_actor_ips([], [
+ ['id' => 'new', 'ips' => ['9.9.9.9'], 'last_seen' => 900],
+ ]);
+ $acc = WebDecoy_Actor_Feed::merge_actor_ips($acc, [
+ ['id' => 'old', 'ips' => ['9.9.9.9'], 'last_seen' => 100],
+ ]);
+ $same('new', $acc['9.9.9.9']['actor_id'], 'stale duplicate is ignored');
+ $same(900, $acc['9.9.9.9']['last_seen']);
+});
+
+echo "\nActor Feed: allowlist exclusion\n";
+
+$t('never emits an allowlisted or invalid IP', function () use ($same, $true) {
+ $map = [
+ '1.1.1.1' => ['actor_id' => 'a', 'last_seen' => 1],
+ '2.2.2.2' => ['actor_id' => 'b', 'last_seen' => 2], // allowlisted
+ 'not-an-ip' => ['actor_id' => 'c', 'last_seen' => 3], // invalid
+ '2001:db8::1' => ['actor_id' => 'd', 'last_seen' => 4],
+ ];
+ $out = WebDecoy_Actor_Feed::exclude_allowlisted($map, ['2.2.2.2']);
+
+ $true(isset($out['1.1.1.1']), 'non-allowlisted ipv4 kept');
+ $true(isset($out['2001:db8::1']), 'valid ipv6 kept');
+ $true(!isset($out['2.2.2.2']), 'exact allowlist match excluded');
+ $true(!isset($out['not-an-ip']), 'invalid ip excluded');
+ $same(2, count($out));
+});
+
+echo "\nActor Feed: cap / eviction ordering\n";
+
+$t('no eviction while at or under the cap', function () use ($same) {
+ $rows = ['1.1.1.1' => 100, '2.2.2.2' => 200];
+ $same([], WebDecoy_Actor_Feed::select_evictions($rows, 2), 'exactly at cap keeps everything');
+ $same([], WebDecoy_Actor_Feed::select_evictions($rows, 5), 'under cap keeps everything');
+});
+
+$t('evicts the oldest-seen rows first, exactly the overage', function () use ($same) {
+ $rows = [
+ 'newest' => 500,
+ 'oldest' => 100,
+ 'middle' => 300,
+ ];
+ // Cap of 1 -> must drop the two oldest, keeping only "newest".
+ $evict = WebDecoy_Actor_Feed::select_evictions($rows, 1);
+ $same(['oldest', 'middle'], $evict, 'ascending last_seen order, count == overage');
+});
+
+echo "\nActor Feed: pagination continue decision\n";
+
+$t('stops on complete, stalled cursor, or a cap; else continues', function () use ($true) {
+ // complete -> stop even if a cursor was returned.
+ $true(WebDecoy_Actor_Feed::should_continue(true, true, 1, 25, 10, 2000) === false, 'complete stops');
+ // cursor did not advance -> stop (guards against an infinite loop).
+ $true(WebDecoy_Actor_Feed::should_continue(false, false, 1, 25, 10, 2000) === false, 'stalled cursor stops');
+ // page cap reached -> stop.
+ $true(WebDecoy_Actor_Feed::should_continue(false, true, 25, 25, 10, 2000) === false, 'page cap stops');
+ // row cap reached -> stop.
+ $true(WebDecoy_Actor_Feed::should_continue(false, true, 1, 25, 2000, 2000) === false, 'row cap stops');
+ // otherwise keep paginating.
+ $true(WebDecoy_Actor_Feed::should_continue(false, true, 1, 25, 10, 2000) === true, 'incomplete + advanced continues');
+});
diff --git a/tests/ActorIntelTest.php b/tests/ActorIntelTest.php
new file mode 100644
index 0000000..441c6f2
--- /dev/null
+++ b/tests/ActorIntelTest.php
@@ -0,0 +1,108 @@
+ 1, // truthy -> true
+ 'network_detections' => '42', // numeric string -> int
+ 'first_seen' => 1600000000,
+ 'last_seen' => 1600100000,
+ 'redacted' => false,
+ 'tor' => 1,
+ 'vpn' => 0,
+ 'abuse_score' => 250, // clamped to 100
+ 'actor' => ['id' => 'act_7', 'sites' => '9'],
+ ]);
+
+ $same(true, $r['known']);
+ $same(42, $r['network_detections']);
+ $same(1600000000, $r['first_seen']);
+ $same(1600100000, $r['last_seen']);
+ $same(false, $r['redacted']);
+ $same(true, $r['tor'], 'truthy tor coerced to bool');
+ $same(false, $r['vpn'], 'falsy vpn coerced to bool');
+ $same(100, $r['abuse_score'], 'abuse score clamped to 0..100');
+ $true(is_array($r['actor']), 'actor present');
+ $same('act_7', $r['actor']['id']);
+ $same(9, $r['actor']['sites']);
+});
+
+$t('a redacted (free-connected) result withholds every enriched field', function () use ($same, $null) {
+ $r = WebDecoy_Actor_Intel::normalize_result([
+ 'known' => true,
+ 'network_detections' => 5,
+ 'first_seen' => 1600000000,
+ 'last_seen' => 1600100000,
+ 'redacted' => true,
+ // Even if the server leaks these, the client must NOT surface them:
+ 'tor' => true,
+ 'vpn' => true,
+ 'abuse_score' => 88,
+ 'actor' => ['id' => 'act_x', 'sites' => 3],
+ ]);
+
+ $same(true, $r['known'], 'teaser still exposes known');
+ $same(5, $r['network_detections'], 'teaser still exposes the count');
+ $same(1600000000, $r['first_seen']);
+ $same(true, $r['redacted']);
+ $null($r['tor'], 'tor withheld when redacted');
+ $null($r['vpn'], 'vpn withheld when redacted');
+ $null($r['abuse_score'], 'abuse score withheld when redacted');
+ $null($r['actor'], 'actor withheld when redacted');
+});
+
+$t('a sparse result fills sane defaults and never fatals', function () use ($same, $null) {
+ $r = WebDecoy_Actor_Intel::normalize_result([]);
+ $same(false, $r['known']);
+ $same(0, $r['network_detections']);
+ $same(0, $r['first_seen']);
+ $same(0, $r['last_seen']);
+ $same(false, $r['redacted']);
+ $null($r['tor']);
+ $null($r['vpn']);
+ $null($r['abuse_score']);
+ $null($r['actor']);
+});
+
+$t('negative counts are floored at zero', function () use ($same) {
+ $r = WebDecoy_Actor_Intel::normalize_result(['network_detections' => -3, 'abuse_score' => -10]);
+ $same(0, $r['network_detections']);
+ $same(0, $r['abuse_score']);
+});
+
+echo "\nActor Intel: IP batching\n";
+
+$t('unique_ips dedupes, drops invalids, and caps the batch', function () use ($same) {
+ $out = WebDecoy_Actor_Intel::unique_ips(
+ ['1.1.1.1', '1.1.1.1', ' 2.2.2.2 ', 'nope', '', 3, '2001:db8::5'],
+ 50
+ );
+ $same(['1.1.1.1', '2.2.2.2', '2001:db8::5'], $out, 'deduped, trimmed, invalids removed');
+});
+
+$t('unique_ips honors the max cap', function () use ($same) {
+ $out = WebDecoy_Actor_Intel::unique_ips(['1.1.1.1', '2.2.2.2', '3.3.3.3'], 2);
+ $same(['1.1.1.1', '2.2.2.2'], $out, 'stops at the cap');
+ $same([], WebDecoy_Actor_Intel::unique_ips(['1.1.1.1'], 0), 'cap of 0 yields nothing');
+});
diff --git a/tests/CriticalMomentTest.php b/tests/CriticalMomentTest.php
new file mode 100644
index 0000000..a835a11
--- /dev/null
+++ b/tests/CriticalMomentTest.php
@@ -0,0 +1,58 @@
+ allow');
+ $true(WebDecoy_Critical_Moment::should_queue(-1, 1000000, $week) === true, 'sentinel <= 0 -> allow');
+});
+
+$t('throttle blocks within the window and reopens at the boundary', function () use ($true, $week) {
+ $now = 10000000;
+ $true(WebDecoy_Critical_Moment::should_queue($now - 1, $now, $week) === false, '1s later still blocked');
+ $true(WebDecoy_Critical_Moment::should_queue($now - ($week - 1), $now, $week) === false, 'just inside window blocked');
+ $true(WebDecoy_Critical_Moment::should_queue($now - $week, $now, $week) === true, 'exactly at window reopens');
+ $true(WebDecoy_Critical_Moment::should_queue($now - ($week + 1), $now, $week) === true, 'past window allowed');
+});
+
+echo "\nCritical Moment: days since first seen\n";
+
+$t('days_since floors whole days and clamps to zero', function () use ($same) {
+ $now = 1000000000;
+ $same(0, WebDecoy_Critical_Moment::days_since(0, $now), 'no first_seen -> 0');
+ $same(0, WebDecoy_Critical_Moment::days_since($now + 5000, $now), 'future first_seen -> 0');
+ $same(0, WebDecoy_Critical_Moment::days_since($now - 3600, $now), 'under a day -> 0');
+ $same(1, WebDecoy_Critical_Moment::days_since($now - 86400, $now), 'exactly one day');
+ $same(3, WebDecoy_Critical_Moment::days_since($now - (3 * 86400 + 100), $now), 'floors partial days');
+});
+
+echo "\nCritical Moment: copy variant\n";
+
+$t('variant reflects connection, intel, and entitlement', function () use ($same) {
+ $same('unconnected', WebDecoy_Critical_Moment::variant(false, false, false), 'not connected');
+ $same('unconnected', WebDecoy_Critical_Moment::variant(false, true, true), 'connection is the gate');
+ $same('connected_unknown', WebDecoy_Critical_Moment::variant(true, false, false), 'connected but IP unknown');
+ $same('connected_upgrade', WebDecoy_Critical_Moment::variant(true, true, false), 'known + no feed -> upgrade pitch');
+ $same('connected_covered', WebDecoy_Critical_Moment::variant(true, true, true), 'known + feed -> confirmation copy');
+});
|