 . '202-img/icons/16x16/page_white_excel.png)
@@ -1651,12 +1654,12 @@ public function displayVariableReport($theData)
public function downloadReport($reportType, $theData, $foundRows = '')
{
- global $userObj;
-
$featureLabel = self::FEATURE_LABELS[$reportType] ?? 'Item';
echo $featureLabel . "\t" . "Clicks" . "\t" . "Click Throughs" . "\t" . "LP CTR" . "\t" . "Leads" . "\t" . "S/U" . "\t" . "Payout" . "\t" . "EPC" . "\t" . "Avg CPC" . "\t" . "Income" . "\t" . "Cost" . "\t" . "Net" . "\t" . "ROI" . "\n";
+ $masked = \Prosper202\Report\CampaignDataMask::hidden();
+
foreach (array_values((array) $theData) as $html) {
// The trailing totals row carries only total_* keys; letting it
// fall through printed an "Unknown" row of empty cells (plus
@@ -1691,13 +1694,8 @@ public function downloadReport($reportType, $theData, $foundRows = '')
continue;
}
- if ($userObj && !$userObj->hasPermission("access_to_campaign_data") && empty($_SESSION['publisher'])) {
- $html['clicks'] = '?';
- $html['click_out'] = '?';
- $html['leads'] = '?';
- $html['income'] = '?';
- $html['cost'] = '?';
- $html['net'] = '?';
+ if ($masked) {
+ $html = \Prosper202\Report\CampaignDataMask::apply($html);
}
echo $featureKey . "\t" . $html['clicks'] . "\t" . $html['click_out'] . "\t" . $html['ctr'] . "\t" . $html['leads'] . "\t" . $html['su_ratio'] . "\t" . $html['payout'] . "\t" . $html['epc'] . "\t" . $html['cpc'] . "\t" . $html['income'] . "\t" . $html['cost'] . "\t" . $html['net'] . "\t" . $html['roi'] . "\n";
@@ -1706,6 +1704,8 @@ public function downloadReport($reportType, $theData, $foundRows = '')
public function downloadVariables($theData)
{
+ $theData = $this->maskVariableData($theData);
+
echo "Custom Variables" . "\t" . "Clicks" . "\t" . "Click Throughs" . "\t" . "LP CTR" . "\t" . "Leads" . "\t" . "S/U" . "\t" . "Payout" . "\t" . "EPC" . "\t" . "Avg CPC" . "\t" . "Income" . "\t" . "Cost" . "\t" . "Net" . "\t" . "ROI" . "\n";
$rows = array_values((array) $theData);
diff --git a/202-config/class-indexes.php b/202-config/class-indexes.php
index 4c1cdf88..457b0af2 100644
--- a/202-config/class-indexes.php
+++ b/202-config/class-indexes.php
@@ -645,7 +645,7 @@ public static function get_platform_id($platform_name)
return $platform_id;
}
- public static function get_device_id($device_name)
+ public static function get_device_id($device_name, $device_type = null)
{
$database = DB::getInstance();
$db = $database->getConnection();
@@ -655,11 +655,22 @@ public static function get_device_id($device_name)
}
$mysql['device_name'] = $db->real_escape_string(trim((string) $device_name));
- $device_sql = "SELECT device_id FROM 202_devices WHERE device_name='" . $mysql['device_name'] . "'";
- $device_result = $db->query($device_sql); // or record_mysql_error($device_sql);
+ // 202_device_models is the real catalog; 202_devices is created by no
+ // install path. device_type is NOT NULL with no default, so supply it —
+ // but it must be a real type. 202_device_types seeds only 1=Desktop,
+ // 2=Mobile, 3=Tablet, 4=Bot, so a 0 joined to nothing and dropped the
+ // model out of every `device_type = N` report filter permanently, since
+ // rows here are keyed on device_name. Mirror connect2.php, which resolves
+ // 1-4 and falls back to 1 for an unrecognised device.
+ $type = (int) $device_type;
+ if ($type < 1 || $type > 4) {
+ $type = 1;
+ }
+ $device_sql = "SELECT device_id FROM 202_device_models WHERE device_name='" . $mysql['device_name'] . "'";
+ $device_result = $db->query($device_sql) or record_mysql_error($device_sql);
if ($device_result->num_rows == 0) {
- $device_sql = "INSERT INTO 202_devices SET device_name='" . $mysql['device_name'] . "'";
+ $device_sql = "INSERT INTO 202_device_models SET device_name='" . $mysql['device_name'] . "', device_type='" . $type . "'";
delay_sql($device_sql);
$device_id = mysqli_insert_id($db);
} else {
diff --git a/202-config/functions-auth.php b/202-config/functions-auth.php
index d9290b89..63025da2 100755
--- a/202-config/functions-auth.php
+++ b/202-config/functions-auth.php
@@ -340,9 +340,11 @@ public static function is_valid_api_key($user_api_key)
if ($keyIsValid) {
//update the api key
+ global $db;
+ $escaped_api_key = $db->real_escape_string((string) $user_api_key);
$user_sql = " UPDATE 202_users
- SET p202_customer_api_key='" . $user_api_key . "'
- WHERE user_id='" . $_SESSION['user_id'] . "'";
+ SET p202_customer_api_key='" . $escaped_api_key . "'
+ WHERE user_id='" . (int) $_SESSION['user_id'] . "'";
_mysqli_query($user_sql);
self::writeSessionValue('valid_key', true);
// Warm the CLI shell license cache so p202 shell works without its own round-trip.
diff --git a/202-config/functions-indexes.php b/202-config/functions-indexes.php
index 999811dc..4e34efaa 100644
--- a/202-config/functions-indexes.php
+++ b/202-config/functions-indexes.php
@@ -107,8 +107,8 @@ function get_platform_id($platform_name)
}
if (!function_exists('get_device_id')) {
- function get_device_id($device_name)
+ function get_device_id($device_name, $device_type = null)
{
- return INDEXES::get_device_id($device_name);
+ return INDEXES::get_device_id($device_name, $device_type);
}
}
diff --git a/202-config/functions-tracking202.php b/202-config/functions-tracking202.php
index 7f4eb361..3856b6de 100644
--- a/202-config/functions-tracking202.php
+++ b/202-config/functions-tracking202.php
@@ -1540,19 +1540,22 @@ function query(
}
if ($user_row['user_pref_country_id']) {
- $mysql['user_pref_country_id'] = $db->real_escape_string($user_row['user_pref_country_id']);
+ // Cast to int: these ids are interpolated UNQUOTED, where
+ // real_escape_string does not neutralize a payload like
+ // "1 OR (SELECT ...)" (it contains no quotes to escape).
+ $mysql['user_pref_country_id'] = (int) $user_row['user_pref_country_id'];
$click_sql .= " AND 2ca.country_id=" . $mysql['user_pref_country_id'];
$count_where .= " AND 2c.country_id=" . $mysql['user_pref_country_id'];
}
if ($user_row['user_pref_region_id']) {
- $mysql['user_pref_region_id'] = $db->real_escape_string($user_row['user_pref_region_id']);
+ $mysql['user_pref_region_id'] = (int) $user_row['user_pref_region_id'];
$click_sql .= " AND 2ca.region_id=" . $mysql['user_pref_region_id'];
$count_where .= " AND 2c.region_id=" . $mysql['user_pref_region_id'];
}
if ($user_row['user_pref_isp_id']) {
- $mysql['user_pref_isp_id'] = $db->real_escape_string($user_row['user_pref_isp_id']);
+ $mysql['user_pref_isp_id'] = (int) $user_row['user_pref_isp_id'];
$click_sql .= " AND 2is.isp_id=" . $mysql['user_pref_isp_id'];
$count_where .= " AND 2c.isp_id=" . $mysql['user_pref_isp_id'];
}
@@ -1578,19 +1581,19 @@ function query(
}
if ($user_row['user_pref_device_id']) {
- $mysql['user_pref_device_id'] = $db->real_escape_string($user_row['user_pref_device_id']);
+ $mysql['user_pref_device_id'] = (int) $user_row['user_pref_device_id'];
$click_sql .= " AND 2d.device_type=" . $mysql['user_pref_device_id'];
$count_where .= " AND 2c.device_id IN (SELECT device_id FROM 202_device_models WHERE device_type=" . $mysql['user_pref_device_id'] . ")";
}
if ($user_row['user_pref_browser_id']) {
- $mysql['user_pref_browser_id'] = $db->real_escape_string($user_row['user_pref_browser_id']);
+ $mysql['user_pref_browser_id'] = (int) $user_row['user_pref_browser_id'];
$click_sql .= " AND 2b.browser_id=" . $mysql['user_pref_browser_id'];
$count_where .= " AND 2c.browser_id=" . $mysql['user_pref_browser_id'];
}
if ($user_row['user_pref_platform_id']) {
- $mysql['user_pref_platform_id'] = $db->real_escape_string($user_row['user_pref_platform_id']);
+ $mysql['user_pref_platform_id'] = (int) $user_row['user_pref_platform_id'];
$click_sql .= " AND 2p.platform_id=" . $mysql['user_pref_platform_id'];
$count_where .= " AND 2c.platform_id=" . $mysql['user_pref_platform_id'];
}
diff --git a/202-config/functions-upgrade.php b/202-config/functions-upgrade.php
index 5855ae0d..1d4b4dc9 100755
--- a/202-config/functions-upgrade.php
+++ b/202-config/functions-upgrade.php
@@ -3072,7 +3072,14 @@ public static function upgrade_databases($time_from)
$connection = $database->getConnection();
if ($connection instanceof \mysqli) {
- $connection->begin_transaction();
+ // Checked: on a false return the ALTERs and the seed UPDATE below
+ // run in autocommit, so the rollback in the catch does nothing and
+ // a failed upgrade leaves 202_attribution_settings half-migrated
+ // while the version row is never advanced -- the next run then
+ // re-applies the same steps against the partially changed schema.
+ if (!$connection->begin_transaction()) {
+ throw new \RuntimeException('Failed to start the 1.9.57 upgrade transaction: ' . $connection->error);
+ }
try {
$columnChecks = [
@@ -3133,7 +3140,9 @@ public static function upgrade_databases($time_from)
throw new \RuntimeException('Failed to seed attribution setting toggles: ' . $connection->error);
}
- $connection->commit();
+ if (!$connection->commit()) {
+ throw new \RuntimeException('Failed to commit the 1.9.57 upgrade: ' . $connection->error);
+ }
} catch (\Throwable $upgradeException) {
$connection->rollback();
throw $upgradeException;
@@ -3176,6 +3185,36 @@ public static function upgrade_databases($time_from)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;";
$result = _upgrade_query($sql);
+ // The 1.9.56 block above already created 202_attribution_exports with
+ // a DIFFERENT column set, so the CREATE ... IF NOT EXISTS just above
+ // is a no-op on every install and the columns MysqlExportRepository
+ // selects never existed (ExportFormat::from('') -> uncaught
+ // ValueError, surfacing as a 500 on 202-account/attribution-export.php).
+ // Add the missing columns explicitly.
+ $exportColumnsToAdd = [
+ 'format' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `format` varchar(10) NOT NULL DEFAULT 'csv'",
+ 'download_token' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `download_token` varchar(64) DEFAULT NULL",
+ 'webhook_method' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `webhook_method` varchar(10) DEFAULT NULL",
+ 'last_attempted_at' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `last_attempted_at` int(10) unsigned DEFAULT NULL",
+ 'error_message' => "ALTER TABLE `202_attribution_exports` ADD COLUMN `error_message` text DEFAULT NULL",
+ ];
+ foreach ($exportColumnsToAdd as $exportColumn => $exportAlterSql) {
+ $sql = "SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = '202_attribution_exports'
+ AND COLUMN_NAME = '" . $exportColumn . "'";
+ $result = _upgrade_query($sql);
+ $row = mysqli_fetch_assoc($result);
+ if ($row['count'] == 0) {
+ $result = _upgrade_query($exportAlterSql);
+ }
+ }
+
+ // Backfill the renamed columns so pre-existing rows are readable.
+ $result = _upgrade_query("UPDATE `202_attribution_exports` SET `format` = `requested_format` WHERE `format` = 'csv' AND `requested_format` IS NOT NULL AND `requested_format` != ''");
+ $result = _upgrade_query("UPDATE `202_attribution_exports` SET `last_attempted_at` = `webhook_attempted_at` WHERE `last_attempted_at` IS NULL AND `webhook_attempted_at` IS NOT NULL");
+ $result = _upgrade_query("UPDATE `202_attribution_exports` SET `error_message` = `last_error` WHERE `error_message` IS NULL AND `last_error` IS NOT NULL");
+
$sql = "UPDATE 202_version SET version='1.9.59'";
$result = _upgrade_query($sql);
diff --git a/202-config/migrations/run_attribution_migration.php b/202-config/migrations/run_attribution_migration.php
index f391d7f3..ec56aba3 100644
--- a/202-config/migrations/run_attribution_migration.php
+++ b/202-config/migrations/run_attribution_migration.php
@@ -42,7 +42,13 @@ function($stmt) {
echo "Found " . count($statements) . " SQL statements to execute...\n";
// Execute each statement
- $db->begin_transaction();
+ // No transaction here, on purpose. The statements in this file are CREATE
+ // TABLE / ALTER TABLE with one INSERT IGNORE between them, and MySQL commits
+ // implicitly before and after every DDL statement -- so a transaction
+ // around this loop can never roll anything back, and the rollback() that
+ // used to sit in the catch block only made a failed run look recoverable.
+ // The script is re-runnable instead: IF NOT EXISTS / INSERT IGNORE make a
+ // second pass after a failure a no-op for everything that already landed.
foreach ($statements as $index => $statement) {
echo "Executing statement " . ($index + 1) . "...\n";
@@ -59,8 +65,6 @@ function($stmt) {
}
}
- $db->commit();
-
echo "\nMigration completed successfully!\n";
echo "Attribution models tables have been created.\n";
@@ -96,7 +100,6 @@ function($stmt) {
}
} catch (Exception $e) {
- $db->rollback();
echo "\nMigration failed: " . $e->getMessage() . "\n";
exit(1);
}
diff --git a/202-config/migrations/run_attribution_migration_standalone.php b/202-config/migrations/run_attribution_migration_standalone.php
index 8de580d2..f0aae15d 100644
--- a/202-config/migrations/run_attribution_migration_standalone.php
+++ b/202-config/migrations/run_attribution_migration_standalone.php
@@ -63,7 +63,13 @@ function($stmt) {
echo "Found " . count($statements) . " SQL statements to execute...\n\n";
// Execute each statement
- $db->begin_transaction();
+ // No transaction here, on purpose. The statements in this file are CREATE
+ // TABLE / ALTER TABLE with one INSERT IGNORE between them, and MySQL commits
+ // implicitly before and after every DDL statement -- so a transaction
+ // around this loop can never roll anything back, and the rollback() that
+ // used to sit in the catch block only made a failed run look recoverable.
+ // The script is re-runnable instead: IF NOT EXISTS / INSERT IGNORE make a
+ // second pass after a failure a no-op for everything that already landed.
foreach ($statements as $index => $statement) {
echo "Executing statement " . ($index + 1) . "... ";
@@ -83,8 +89,6 @@ function($stmt) {
echo "\n";
}
- $db->commit();
-
echo "\n🎉 Migration completed successfully!\n";
echo "Attribution models tables have been created.\n\n";
@@ -132,7 +136,6 @@ function($stmt) {
}
} catch (Exception $e) {
- $db->rollback();
echo "\n❌ Migration failed: " . $e->getMessage() . "\n";
exit(1);
}
diff --git a/202-config/migrations/run_forecast_events_migration.php b/202-config/migrations/run_forecast_events_migration.php
index 2ab0575a..022f66db 100644
--- a/202-config/migrations/run_forecast_events_migration.php
+++ b/202-config/migrations/run_forecast_events_migration.php
@@ -9,6 +9,8 @@
include_once dirname(__DIR__) . '/connect.php';
+use Prosper202\Database\Connection;
+
if (!isset($db) || !($db instanceof mysqli)) {
die("Error: Database connection not available\n");
}
@@ -91,15 +93,14 @@ function ($stmt) {
$dml = [];
}
- $db->begin_transaction();
- $inTransaction = true;
-
- foreach ($dml as $statement) {
- $runStatement($statement);
- }
-
- $db->commit();
- $inTransaction = false;
+ // Connection::transaction() does the checked begin, the checked commit and
+ // the rollback-on-throw; hand-rolling those here is how the unchecked
+ // begin_transaction() got in. run_ltv_backfill.php uses the same shape.
+ (new Connection($db))->transaction(static function () use ($dml, $runStatement): void {
+ foreach ($dml as $statement) {
+ $runStatement($statement);
+ }
+ });
echo "\nMigration completed successfully!\n";
@@ -123,10 +124,8 @@ function ($stmt) {
}
}
-} catch (Exception $e) {
- if (!empty($inTransaction)) {
- $db->rollback();
- }
+} catch (Throwable $e) {
+ // Connection::transaction() has already rolled back if the seed failed.
echo "\nMigration failed: " . $e->getMessage() . "\n";
exit(1);
}
diff --git a/202-cronjobs/attribution-export.php b/202-cronjobs/attribution-export.php
index 28871b61..2bd1bdac 100644
--- a/202-cronjobs/attribution-export.php
+++ b/202-cronjobs/attribution-export.php
@@ -285,12 +285,31 @@ function dispatchWebhook(ExportJob $job, array $fileInfo): array
$headers[] = 'X-Prosper202-Signature: ' . $signature;
}
+ // Full check at dispatch: the write boundary only checked shape, and DNS
+ // can change between scheduling and delivery anyway. The validated
+ // addresses feed curlOptions(), which pins the connection to one of them so
+ // curl cannot be handed a rebound private address by its own lookup.
+ try {
+ $validatedIps = \Prosper202\Validation\OutboundUrlGuard::assertAllowed($webhook->url, 'webhook_url');
+ } catch (\Prosper202\Validation\OutboundUrlException $e) {
+ error_log('attribution-export: refusing webhook delivery: ' . $e->getMessage());
+ return [
+ 'success' => false,
+ 'attempted_at' => time(),
+ 'status_code' => null,
+ 'response_body' => null,
+ 'error' => $e->getMessage(),
+ ];
+ }
+
$ch = curl_init($webhook->url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ // Pinned to a validated address, no redirects, https only, TLS verified --
+ // the same option set ltv_webhooks.php uses, so neither can drop one.
+ curl_setopt_array($ch, \Prosper202\Validation\OutboundUrlGuard::curlOptions($webhook->url, $validatedIps));
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: null;
diff --git a/202-cronjobs/attribution-rebuild.php b/202-cronjobs/attribution-rebuild.php
index 93cf1e0a..4afcf17c 100644
--- a/202-cronjobs/attribution-rebuild.php
+++ b/202-cronjobs/attribution-rebuild.php
@@ -44,6 +44,27 @@
$cronBucket = (int) ($endTime - ($endTime % 3600));
$cronType = 'attr';
+// The check-then-insert window guard below is not atomic and 202_cronjobs has
+// no unique key on (cronjob_type, cronjob_time), so two overlapping runs could
+// both pass the check and rebuild the same bucket. Take an exclusive
+// non-blocking file lock first; the loser exits instead of duplicating work.
+$attrLockPath = sys_get_temp_dir() . '/p202-attribution-rebuild.lock';
+$attrLockHandle = fopen($attrLockPath, 'c+');
+if ($attrLockHandle === false) {
+ fwrite(STDERR, "Unable to open attribution cron lock file.\n");
+ exit(1);
+}
+if (!flock($attrLockHandle, LOCK_EX | LOCK_NB)) {
+ fclose($attrLockHandle);
+ fwrite(STDOUT, "Attribution cron is already running; skipping.\n");
+ exit(0);
+}
+// Released implicitly when the process exits.
+register_shutdown_function(static function () use ($attrLockHandle): void {
+ flock($attrLockHandle, LOCK_UN);
+ fclose($attrLockHandle);
+});
+
$database = DB::getInstance();
$connection = $database?->getConnection();
if ($connection instanceof mysqli) {
diff --git a/202-cronjobs/bridge_config.php b/202-cronjobs/bridge_config.php
index 8f20767b..2e1942f2 100644
--- a/202-cronjobs/bridge_config.php
+++ b/202-cronjobs/bridge_config.php
@@ -12,7 +12,7 @@
* both sides sign json_encode(config) with PHP defaults), persists it to
* 202_users_pref.lpo_bridge_config, and applies it to the local webhook
* row: enabled_events maps onto subscribed_events ('*' = '' = subscribe-all)
- * and a hook_url change re-runs the SSRF guard (assertUrlAllowed) before the
+ * and a hook_url change re-runs the SSRF guard (OutboundUrlGuard::assertWellFormed) before the
* URL is updated. This makes event routing and endpoints adjustable
* server-side after install, without a Prosper202 release.
*
@@ -167,7 +167,7 @@
// Apply a hook_url change, re-running the SSRF guard first.
$newUrl = trim((string) ($config['hook_url'] ?? ''));
if ($newUrl !== '' && $newUrl !== (string) $hook['webhook_url']) {
- MysqlWebhookRepository::assertUrlAllowed($newUrl);
+ \Prosper202\Validation\OutboundUrlGuard::assertWellFormed($newUrl, 'hook_url');
$update = $conn->prepareWrite(
'UPDATE 202_ltv_webhooks SET webhook_url = ?, updated_at = ? WHERE webhook_id = ? AND user_id = ?'
);
diff --git a/202-cronjobs/daily-email.php b/202-cronjobs/daily-email.php
index 5b6f616a..595a4c74 100755
--- a/202-cronjobs/daily-email.php
+++ b/202-cronjobs/daily-email.php
@@ -72,8 +72,13 @@
}
}
+ // Only run the comparison query when today produced campaigns. With an empty
+ // $ids the IN () below is a MySQL syntax error, which the outer catch
+ // swallows to error_log — silently skipping the whole daily email on any
+ // day that starts with no data.
+ if ($ids !== []) {
$sql_yesterday = "SELECT
- 2c.aff_campaign_id,
+ 2c.aff_campaign_id,
2ca.aff_campaign_name,
COUNT(*) AS clicks,
SUM(2cr.click_out) AS click_throughs,
@@ -118,6 +123,7 @@
$data['campaigns'][$row_yesterday['aff_campaign_id']]['difference'] = $difference;
}
}
+ } // end if ($ids !== [])
if (count($data['campaigns']) > 0) {
$curl = curl_init('https://my.tracking202.com/api/v2/send-daily-email');
diff --git a/202-cronjobs/ltv_webhooks.php b/202-cronjobs/ltv_webhooks.php
index f9d6a0f2..9c07437a 100644
--- a/202-cronjobs/ltv_webhooks.php
+++ b/202-cronjobs/ltv_webhooks.php
@@ -29,6 +29,7 @@
use Prosper202\Database\Connection;
use Prosper202\Ltv\MysqlWebhookRepository;
+use Prosper202\Validation\OutboundUrlGuard;
set_time_limit(0);
@@ -73,22 +74,6 @@
continue;
}
- // Pin the connection to an address the guard just validated —
- // otherwise curl re-resolves and a DNS-rebinding host could hand it
- // a private IP the check never saw. Prefer IPv4; TLS host
- // verification still runs against the hostname's certificate.
- $pinnedIp = null;
- foreach ($validatedIps as $candidateIp) {
- if (filter_var($candidateIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
- $pinnedIp = $candidateIp;
- break;
- }
- }
- $pinnedIp = $pinnedIp ?? $validatedIps[0];
- $urlParts = parse_url($url);
- $pinHost = (string) ($urlParts['host'] ?? '');
- $pinPort = (int) ($urlParts['port'] ?? 443);
-
$signature = MysqlWebhookRepository::signature($body, (string) $delivery['webhook_secret']);
$ch = curl_init($url);
@@ -108,15 +93,10 @@
'User-Agent: Prosper202-LTV-Webhook/1.0',
],
CURLOPT_RETURNTRANSFER => true,
- CURLOPT_FOLLOWLOCATION => false, // SSRF: never follow redirects
- CURLOPT_MAXREDIRS => 0,
- CURLOPT_CONNECTTIMEOUT => 5,
- CURLOPT_TIMEOUT => 15,
- CURLOPT_SSL_VERIFYPEER => true,
- CURLOPT_SSL_VERIFYHOST => 2,
- CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_RESOLVE => [$pinHost . ':' . $pinPort . ':' . $pinnedIp],
]);
+ // Pin to an address the guard just validated, no redirects, https only,
+ // TLS verified -- one shared option set so no dispatcher can drop one.
+ curl_setopt_array($ch, OutboundUrlGuard::curlOptions($url, $validatedIps));
$responseBody = curl_exec($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
diff --git a/202-cronjobs/process_dataengine_job.php b/202-cronjobs/process_dataengine_job.php
index 46ef2d71..a11df078 100755
--- a/202-cronjobs/process_dataengine_job.php
+++ b/202-cronjobs/process_dataengine_job.php
@@ -25,8 +25,21 @@
$mysql['click_time_from'] = $db->real_escape_string((string)$row['time_from']);
$mysql['click_time_to'] = $db->real_escape_string((string)$row['time_to']);
- $sql = "UPDATE 202_dataengine_job SET processing = '1' WHERE time_from ='" . $mysql['click_time_from'] . "' AND time_to = '" . $mysql['click_time_to'] . "'";
+ // Atomic compare-and-swap claim: the SELECT above is not a lock, so
+ // two overlapping cron runs can both read processing=0 for the same
+ // window. Only the run whose UPDATE actually flips processing 0->1
+ // (affected_rows === 1) may aggregate the hour; the loser bails out
+ // instead of double-processing the window into the DataEngine.
+ $sql = "UPDATE 202_dataengine_job SET processing = '1' WHERE time_from ='" . $mysql['click_time_from'] . "' AND time_to = '" . $mysql['click_time_to'] . "' AND processing = '0'";
$db->query($sql);
+ // 202_dataengine_job has no PRIMARY/UNIQUE key, so a duplicated
+ // window legitimately flips more than one row. Require >= 1 (we
+ // won the claim) rather than exactly 1 — bailing out after having
+ // already set processing='1' would strand the window forever,
+ // because the release UPDATEs live below this point.
+ if ($db->affected_rows < 1) {
+ return;
+ }
$urls = [];
for ($i = $mysql['click_time_from']; $i < $mysql['click_time_to']; $i += 3599) {
diff --git a/202-js/dni.search.offers.tablesorter.js b/202-js/dni.search.offers.tablesorter.js
index a125b773..7df3d319 100755
--- a/202-js/dni.search.offers.tablesorter.js
+++ b/202-js/dni.search.offers.tablesorter.js
@@ -48,7 +48,14 @@ $(function() {
$('table.tablesorter').find('tbody').html(rows);
$('span#inProgress').hide();
$('span#inProgressFooter').hide();
- $('h4.modal-title').html(network+'
Processing... 
');
+ // Set the network name as TEXT: it comes from the remote DNI
+ // network's API, so interpolating it into .html() made a hostile
+ // or compromised upstream name execute here. (The same values are
+ // escaped server-side in 202-account/api-integrations.php.) The
+ // spinner markup is a static literal and is appended after.
+ $('h4.modal-title')
+ .text(network == null ? '' : String(network))
+ .append('
Processing... 
');
$('table.tablesorter').css('opacity', '1');
$('[data-toggle="tooltip"]').tooltip();
return [total];
diff --git a/202-login.php b/202-login.php
index dc2ed68a..2b70baab 100755
--- a/202-login.php
+++ b/202-login.php
@@ -149,7 +149,9 @@ function logged_in_redirect($safe_context = false)
$login_server_serialized,
$login_session_serialized
);
- $login_log_stmt->execute();
+ if (!$login_log_stmt->execute()) {
+ prosper_log('login', 'Unable to write login log row: ' . $login_log_stmt->error);
+ }
$login_log_stmt->close();
} elseif ($should_log_attempt) {
prosper_log('login', 'Unable to prepare login log statement: ' . $db->error);
diff --git a/CLAUDE.md b/CLAUDE.md
index dd68bc36..6da61cec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -158,6 +158,27 @@ the lookup keys on (here the key itself, so the same key still lands in the
same file) and bound what a shard retains, or the correctness fix ships a
latency regression.
+### 16. A validation in a hydration path fails the batch, not the record
+`ExportWebhook`'s constructor ran the SSRF guard. That constructor is also the
+row-hydration path: the export cron's `findPending()` maps every pending row
+through it, so one stored `http://` webhook — or one transient DNS failure —
+threw out of the first call and stranded *every* pending export on every tick,
+including jobs with no webhook at all. The guard was right; its placement
+converted a single bad record into a total outage of the queue. Rejecting bad
+input belongs at the write boundary, where a caller is present to be told, and
+again at the point of use, where it can fail one item. A constructor that
+doubles as `fromDatabaseRow()` is neither: it runs over data that is already
+stored, in a loop, with nobody to answer. Before adding a check, ask who is on
+the other end of the throw and how many unrelated records share the call.
+
+The same question applies to any guard whose failure mode is silence. The
+webhook crons pin curl to an address `OutboundUrlGuard` approved, but an
+unbracketed IPv6 literal makes the `CURLOPT_RESOLVE` entry unparseable; curl
+discards it, resolves the host itself, and the DNS-rebinding hole the pin
+exists to close is open again — with the pinning code still sitting there
+looking correct. A guard that can be dropped without an error needs a test that
+asserts the guard's *output*, not just that the call was made.
+
## Go CLI errors must be agent-actionable (`go-cli/`)
The CLI is built for AI agents as much as humans. An agent reads a failure
diff --git a/api/v1/functions.php b/api/v1/functions.php
index c748254c..7ff1555c 100755
--- a/api/v1/functions.php
+++ b/api/v1/functions.php
@@ -2,9 +2,13 @@
declare(strict_types=1);
function getStats($db, $variables): mixed {
$mysql['api_key'] = $db->real_escape_string($variables['apikey']);
- $key_sql = "SELECT *
- FROM `202_api_keys`
- WHERE `api_key`='".$mysql['api_key']."'";
+ // Join 202_users so a soft-deleted user's key stops authenticating, exactly
+ // as api/v3/Auth.php does. Deleting a user must revoke access on EVERY API
+ // version, not just the newest one.
+ $key_sql = "SELECT k.*
+ FROM `202_api_keys` k
+ INNER JOIN `202_users` u ON u.`user_id` = k.`user_id`
+ WHERE k.`api_key`='".$mysql['api_key']."' AND u.`user_deleted` = 0";
$key_result = _mysqli_query($db, $key_sql);
if ($key_result === false) {
return ['msg' => 'Database error', 'error' => true, 'status' => 500];
diff --git a/api/v2/app.php b/api/v2/app.php
index 0e70d081..2e002793 100644
--- a/api/v2/app.php
+++ b/api/v2/app.php
@@ -55,7 +55,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo
$params['user_id'] = $auth;
}
- $payload = array_merge($params, decode_json_body($request));
+ $body = decode_json_body($request);
+ if ($body === null) {
+ return respond_json($response, ['error' => 'Invalid JSON body'], 400);
+ }
+ $payload = array_merge($params, $body);
if ($auth !== null) {
$payload['user_id'] = $auth;
}
@@ -92,7 +96,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo
$params['user_id'] = $auth;
}
- $payload = array_merge($params, decode_json_body($request));
+ $body = decode_json_body($request);
+ if ($body === null) {
+ return respond_json($response, ['error' => 'Invalid JSON body'], 400);
+ }
+ $payload = array_merge($params, $body);
if ($auth !== null) {
$payload['user_id'] = $auth;
}
@@ -107,7 +115,11 @@ function register_attribution_routes(\Slim\App $app, Controller $controller): vo
$params['user_id'] = $auth;
}
- $payload = array_merge($params, decode_json_body($request));
+ $body = decode_json_body($request);
+ if ($body === null) {
+ return respond_json($response, ['error' => 'Invalid JSON body'], 400);
+ }
+ $payload = array_merge($params, $body);
if ($auth !== null) {
$payload['user_id'] = $auth;
}
@@ -276,10 +288,20 @@ function authorize_attribution_request(array $params, string $permission): array
];
}
- // SELECT * (as api/v1 and api/v2 functions.php do) so this keeps working
- // on a schema where the `scope` column has not been added yet: naming
- // the column would fail the prepare and 500 every request instead.
- $stmt = $connection->prepare('SELECT * FROM 202_api_keys WHERE api_key = ? LIMIT 1');
+ // Two constraints, both required:
+ // - k.* rather than a named column list, so this keeps working on a schema
+ // where `scope` has not been added yet; naming it would fail the prepare
+ // and 500 every request. It must stay a wildcard over the key table
+ // because the caller reads $row['scope'] to enforce attenuation, and a
+ // narrower select would make that read absent -> unscoped -> full access.
+ // - the 202_users join, so a soft-deleted user's key stops authenticating
+ // (as api/v3/Auth.php does): deleting a user must revoke access
+ // everywhere, not just in v3.
+ $stmt = $connection->prepare(
+ 'SELECT k.* FROM 202_api_keys k
+ INNER JOIN 202_users u ON u.user_id = k.user_id
+ WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1'
+ );
if ($stmt === false) {
return [
'status' => 500,
@@ -374,9 +396,13 @@ function user_has_permission(int $userId, string $permission): bool
}
/**
- * @return array
+ * Decode the request body. Returns [] for an empty body and null for a
+ * malformed one — malformed JSON must produce a 400, not be silently
+ * treated as an empty payload.
+ *
+ * @return array|null
*/
-function decode_json_body(Request $request): array
+function decode_json_body(Request $request): ?array
{
$body = (string) $request->getBody();
if ($body === '') {
@@ -386,16 +412,21 @@ function decode_json_body(Request $request): array
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
- return [];
+ return null;
}
- return is_array($decoded) ? $decoded : [];
+ return is_array($decoded) ? $decoded : null;
}
function respond_json(Response $response, array $payload, int $status = 200): Response
{
+ $json = json_encode($payload);
+ if ($json === false) {
+ $status = 500;
+ $json = (string) json_encode(['error' => 'Response encoding failed']);
+ }
$response = $response->withStatus($status);
$response = $response->withHeader('Content-Type', 'application/json');
- $response->getBody()->write(json_encode($payload));
+ $response->getBody()->write($json);
return $response;
}
diff --git a/api/v2/functions.php b/api/v2/functions.php
index 439a8840..f9dd4630 100755
--- a/api/v2/functions.php
+++ b/api/v2/functions.php
@@ -2,9 +2,13 @@
declare(strict_types=1);
function getAuth($db, $variables): mixed {
$mysql['api_key'] = $db->real_escape_string((string) ($variables['apikey'] ?? ''));
- $key_sql = "SELECT *
- FROM `202_api_keys`
- WHERE `api_key`='".$mysql['api_key']."'";
+ // Join 202_users so a soft-deleted user's key stops authenticating, exactly
+ // as api/v3/Auth.php does. Deleting a user must revoke access on EVERY API
+ // version, not just the newest one.
+ $key_sql = "SELECT k.*
+ FROM `202_api_keys` k
+ INNER JOIN `202_users` u ON u.`user_id` = k.`user_id`
+ WHERE k.`api_key`='".$mysql['api_key']."' AND u.`user_deleted` = 0";
$key_result = _mysqli_query($db, $key_sql);
$key_row = $key_result->fetch_assoc();
diff --git a/api/v3/Auth.php b/api/v3/Auth.php
index a61e1330..82e68151 100644
--- a/api/v3/Auth.php
+++ b/api/v3/Auth.php
@@ -28,7 +28,10 @@ private function __construct(
*/
public static function fromRequest(array $headers, \mysqli $db): self
{
- $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
+ // Header names are case-insensitive per RFC 9110; normalize instead of
+ // probing a couple of hardcoded casings.
+ $headers = array_change_key_case($headers, CASE_LOWER);
+ $authHeader = $headers['authorization'] ?? '';
if (is_array($authHeader)) {
$authHeader = $authHeader[0] ?? '';
}
@@ -42,10 +45,12 @@ public static function fromRequest(array $headers, \mysqli $db): self
throw new AuthException('API key required. Pass via Authorization: Bearer header.', 401);
}
+ // Join 202_users so keys belonging to soft-deleted users stop
+ // authenticating — "deleting" a user must actually revoke access.
$scopeColumnExists = self::apiKeyScopeColumnExists($db);
$sql = $scopeColumnExists
- ? 'SELECT user_id, scope FROM 202_api_keys WHERE api_key = ? LIMIT 1'
- : 'SELECT user_id FROM 202_api_keys WHERE api_key = ? LIMIT 1';
+ ? 'SELECT k.user_id, k.scope FROM 202_api_keys k INNER JOIN 202_users u ON u.user_id = k.user_id WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1'
+ : 'SELECT k.user_id FROM 202_api_keys k INNER JOIN 202_users u ON u.user_id = k.user_id WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1';
$stmt = $db->prepare($sql);
if (!$stmt) {
throw new AuthException('Authentication unavailable', 500);
@@ -356,6 +361,10 @@ public static function apiKeyScopeColumnExists(\mysqli $db): bool
*/
private static function probeApiKeyScopeColumn(\mysqli $db): bool
{
+ // Fail closed: a DB error here must not silently drop the scope column
+ // from the auth query (which would grant the key the full '*' scope).
+ // Only a successful probe that finds no column may report false —
+ // that is the legitimate pre-upgrade schema case.
$stmt = $db->prepare("SHOW COLUMNS FROM 202_api_keys LIKE 'scope'");
if (!$stmt) {
throw new AuthException('Authentication unavailable', 500);
@@ -393,8 +402,21 @@ public static function parseScopes(string $raw): array
$scopes = [];
if (str_starts_with($raw, '[')) {
$decoded = json_decode($raw, true);
+ // Unreadable JSON leaves $scopes empty and falls through to the
+ // MALFORMED_SCOPE branch below. That is deliberately not a throw:
+ // a corrupt scope value is a property of one key, so denying that
+ // key with a scope nobody matches is both fail-closed and
+ // diagnosable, whereas a 500 reports a server fault and names no
+ // row.
if (is_array($decoded)) {
foreach ($decoded as $scope) {
+ // Skip non-scalars rather than casting: (string) on an array
+ // warns and yields "Array", inventing a scope name that is
+ // not in the column. Contributing nothing lands a list of
+ // them in MALFORMED_SCOPE like any other unreadable value.
+ if (!is_scalar($scope)) {
+ continue;
+ }
$value = strtolower(trim((string)$scope));
if ($value !== '') {
$scopes[] = $value;
diff --git a/api/v3/Bootstrap.php b/api/v3/Bootstrap.php
index ea85247d..7f09b21f 100644
--- a/api/v3/Bootstrap.php
+++ b/api/v3/Bootstrap.php
@@ -32,6 +32,17 @@ public static function init(): void
require_once $configFile;
+ // 202-config.php assigns $dbhost/$dbuser/... at whatever scope includes
+ // it. When this method is the first include, those land in local scope
+ // and DB::getInstance()'s `global` lookups would find nothing — so
+ // export them. (When index.php already required the config at file
+ // scope, require_once is a no-op and the globals are already set.)
+ foreach (['dbhost', 'dbhostro', 'dbuser', 'dbpass', 'dbname'] as $var) {
+ if (isset($$var) && !isset($GLOBALS[$var])) {
+ $GLOBALS[$var] = $$var;
+ }
+ }
+
$authHelpers = $root . '/202-config/functions-auth.php';
if (file_exists($authHelpers)) {
require_once $authHelpers;
diff --git a/api/v3/Controller.php b/api/v3/Controller.php
index 943f62d9..04be8f85 100644
--- a/api/v3/Controller.php
+++ b/api/v3/Controller.php
@@ -10,6 +10,7 @@
use Api\V3\Exception\ValidationException;
use Api\V3\Exception\WriteCommittedException;
use Api\V3\Support\ServerStateStore;
+use Api\V3\Support\StatementHelpers;
/**
* Base CRUD controller with lifecycle hooks, input validation, and DI.
@@ -20,6 +21,8 @@
*/
abstract class Controller
{
+ use StatementHelpers;
+
abstract protected function tableName(): string;
abstract protected function primaryKey(): string;
abstract protected function fields(): array;
@@ -64,7 +67,11 @@ protected function listOrderBy(): string
return $this->primaryKey() . ' DESC';
}
- protected function maxBulkRows(): int
+ /**
+ * Single source of truth for the bulk-upsert row cap; /capabilities
+ * advertises this value and must never drift from what is enforced.
+ */
+ public static function configuredMaxBulkRows(): int
{
$raw = getenv('P202_MAX_BULK_ROWS');
if (is_string($raw) && trim($raw) !== '') {
@@ -76,6 +83,11 @@ protected function maxBulkRows(): int
return 500;
}
+ protected function maxBulkRows(): int
+ {
+ return self::configuredMaxBulkRows();
+ }
+
protected function selectColumns(): array
{
if ($this->cachedSelectColumns !== null) {
@@ -633,15 +645,30 @@ public function bulkUpsert(array $payload): array
$primaryKey = $this->primaryKey();
$id = $row[$primaryKey] ?? $row['id'] ?? null;
if ($id !== null && $id !== '') {
+ // Strictly validate the ID instead of passing it through
+ // as a string: binding "12abc" as 's' against an integer
+ // PK would let MySQL coerce it to 12 and silently
+ // overwrite the wrong row.
+ if (is_int($id)) {
+ // Already an integer.
+ } elseif (is_string($id) && ctype_digit(trim($id))) {
+ $id = (int)trim($id);
+ } elseif (is_float($id) && $id === (float)(int)$id) {
+ $id = (int)$id;
+ } else {
+ $summary['error']++;
+ $results[] = ['index' => $index, 'status' => 'error', 'message' => 'Invalid primary key value'];
+ continue;
+ }
try {
- $this->get((string)$id);
+ $this->get($id);
$clean = $this->validatePayload($row);
if ($clean === []) {
$summary['skipped']++;
$results[] = ['index' => $index, 'status' => 'skipped', 'message' => 'No mutable fields provided'];
continue;
}
- $updated = $this->update((string)$id, $row);
+ $updated = $this->update($id, $row);
$summary['updated']++;
$results[] = ['index' => $index, 'status' => 'updated', 'data' => $updated['data']];
continue;
@@ -831,43 +858,4 @@ protected function changeEntityName(): ?string
return $map[$this->tableName()] ?? null;
}
- protected function transaction(callable $fn): mixed
- {
- $this->db->begin_transaction();
- try {
- $result = $fn();
- $this->db->commit();
- return $result;
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
- }
-
- protected function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException("Prepare failed");
- }
- return $stmt;
- }
-
- protected function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); no $this->conn exists, cannot self-route
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- protected function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); no $this->conn exists, cannot self-route
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Controllers/AttributionController.php b/api/v3/Controllers/AttributionController.php
index 8dd2168e..8c75c5ed 100644
--- a/api/v3/Controllers/AttributionController.php
+++ b/api/v3/Controllers/AttributionController.php
@@ -4,13 +4,16 @@
namespace Api\V3\Controllers;
-use Api\V3\Exception\DatabaseException;
+use Api\V3\Exception\ConflictException;
use Api\V3\Exception\NotFoundException;
use Api\V3\Exception\WriteCommittedException;
use Api\V3\Exception\ValidationException;
+use Api\V3\Support\StatementHelpers;
class AttributionController
{
+ use StatementHelpers;
+
private const array VALID_MODEL_TYPES = ['first_touch', 'last_touch', 'linear', 'time_decay', 'position_based', 'algorithmic'];
public function __construct(private readonly \mysqli $db, private readonly int $userId)
@@ -69,6 +72,56 @@ public function getModel(int $id): array
return ['data' => $row];
}
+ /**
+ * Validate/encode a weighting_config payload value to a JSON string.
+ * json_encode() failures and non-JSON strings must be explicit errors:
+ * a silently-emptied config makes the model compute garbage attribution.
+ */
+ private function normalizeWeightingConfig(mixed $config): string
+ {
+ if (is_array($config)) {
+ $json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) {
+ throw new ValidationException('weighting_config could not be encoded', ['weighting_config' => json_last_error_msg()]);
+ }
+ return $json;
+ }
+
+ $raw = trim((string)$config);
+ if ($raw === '') {
+ return '{}';
+ }
+ json_decode($raw);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new ValidationException('weighting_config must be valid JSON', ['weighting_config' => json_last_error_msg()]);
+ }
+ return $raw;
+ }
+
+ /**
+ * 202_attribution_models has UNIQUE (user_id, model_slug); surface a
+ * duplicate as 409 instead of letting the INSERT die as a generic 500.
+ */
+ private function assertSlugAvailable(string $slug, ?int $excludeId = null): void
+ {
+ $sql = 'SELECT model_id FROM 202_attribution_models WHERE user_id = ? AND model_slug = ?';
+ $types = 'is';
+ $binds = [$this->userId, $slug];
+ if ($excludeId !== null) {
+ $sql .= ' AND model_id != ?';
+ $types .= 'i';
+ $binds[] = $excludeId;
+ }
+ $stmt = $this->prepare($sql . ' LIMIT 1');
+ $this->bind($stmt, $types, ...$binds);
+ $this->execute($stmt, 'Slug lookup failed');
+ $existing = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ if ($existing) {
+ throw new ConflictException('A model with this slug already exists', ['model_slug' => $slug]);
+ }
+ }
+
public function createModel(array $payload): array
{
$name = trim((string)($payload['model_name'] ?? ''));
@@ -81,11 +134,12 @@ public function createModel(array $payload): array
throw new ValidationException('Invalid model_type', ['model_type' => 'Valid: ' . implode(', ', self::VALID_MODEL_TYPES)]);
}
- $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($name));
- $config = $payload['weighting_config'] ?? '{}';
- if (is_array($config)) {
- $config = json_encode($config);
+ $slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-');
+ if ($slug === '') {
+ throw new ValidationException('model_name must contain at least one alphanumeric character', ['model_name' => 'Cannot derive a slug']);
}
+ $this->assertSlugAvailable($slug);
+ $config = $this->normalizeWeightingConfig($payload['weighting_config'] ?? '{}');
$isActive = (int)($payload['is_active'] ?? 1);
$isDefault = (int)($payload['is_default'] ?? 0);
$now = time();
@@ -113,6 +167,17 @@ public function updateModel(int $id, array $payload): array
}
}
+ // Create rejects empty names/slugs; update must too, and an emptied
+ // slug would additionally break slug-addressed lookups.
+ foreach (['model_name', 'model_slug'] as $requiredField) {
+ if (array_key_exists($requiredField, $payload) && trim((string)$payload[$requiredField]) === '') {
+ throw new ValidationException("$requiredField cannot be empty", [$requiredField => 'Must not be empty']);
+ }
+ }
+ if (array_key_exists('model_slug', $payload)) {
+ $this->assertSlugAvailable(trim((string)$payload['model_slug']), $id);
+ }
+
$sets = [];
$binds = [];
$types = '';
@@ -126,8 +191,7 @@ public function updateModel(int $id, array $payload): array
}
if (array_key_exists('weighting_config', $payload)) {
$sets[] = 'weighting_config = ?';
- $val = is_array($payload['weighting_config']) ? json_encode($payload['weighting_config']) : (string)$payload['weighting_config'];
- $binds[] = $val;
+ $binds[] = $this->normalizeWeightingConfig($payload['weighting_config']);
$types .= 's';
}
@@ -208,8 +272,7 @@ public function deleteModel(int $id): void
{
$this->getModel($id);
- $this->db->begin_transaction();
- try {
+ $this->transaction(function () use ($id): void {
$stmt = $this->prepare('DELETE FROM 202_attribution_touchpoints WHERE snapshot_id IN (SELECT snapshot_id FROM 202_attribution_snapshots WHERE model_id = ? AND user_id = ?)');
$this->bind($stmt, 'ii', $id, $this->userId);
$this->execute($stmt, 'Delete touchpoints failed');
@@ -229,12 +292,7 @@ public function deleteModel(int $id): void
$this->bind($stmt, 'ii', $id, $this->userId);
$this->execute($stmt, 'Delete model failed');
$stmt->close();
-
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
+ });
}
// --- Snapshots ---
@@ -306,6 +364,17 @@ public function scheduleExport(int $modelId, array $payload): array
$endHour = (int)($payload['end_hour'] ?? time());
$format = (string)($payload['format'] ?? 'csv');
$webhookUrl = (string)($payload['webhook_url'] ?? '');
+ // Validate here, at the entry point: this is the only place the caller
+ // can be told their URL is unusable. Shape only, no DNS -- a resolver
+ // stall must not block the request or turn into a 422 for a valid URL;
+ // the cron runs the full check and pins the connection at delivery.
+ if ($webhookUrl !== '') {
+ try {
+ \Prosper202\Validation\OutboundUrlGuard::assertWellFormed($webhookUrl, 'webhook_url');
+ } catch (\Prosper202\Validation\OutboundUrlException $e) {
+ throw new ValidationException($e->getMessage(), ['webhook_url' => $e->getMessage()], $e);
+ }
+ }
$now = time();
// Must be 'pending': the export cron's claimPending() only selects status='pending',
// and 'queued' is not a valid ExportStatus enum value (would fatal on hydration).
@@ -332,31 +401,4 @@ public function scheduleExport(int $modelId, array $payload): array
return ['data' => $row];
}
-
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); class has no $this->conn, cannot self-route
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); class has no $this->conn, cannot self-route
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Controllers/CampaignsController.php b/api/v3/Controllers/CampaignsController.php
index c2364c47..f71db632 100644
--- a/api/v3/Controllers/CampaignsController.php
+++ b/api/v3/Controllers/CampaignsController.php
@@ -22,7 +22,7 @@ protected function fields(): array
'aff_campaign_url_4' => ['type' => 's', 'max_length' => 2048],
'aff_campaign_url_5' => ['type' => 's', 'max_length' => 2048],
'aff_campaign_payout' => ['type' => 'd', 'required' => true],
- 'aff_campaign_currency' => ['type' => 's', 'max_length' => 5],
+ 'aff_campaign_currency' => ['type' => 's', 'max_length' => 3],
'aff_campaign_foreign_payout' => ['type' => 'd', 'default' => 0],
'aff_network_id' => ['type' => 'i', 'required' => true],
'aff_campaign_cloaking' => ['type' => 'i'],
diff --git a/api/v3/Controllers/CapabilitiesController.php b/api/v3/Controllers/CapabilitiesController.php
index 6042e025..ec349e9f 100644
--- a/api/v3/Controllers/CapabilitiesController.php
+++ b/api/v3/Controllers/CapabilitiesController.php
@@ -158,14 +158,8 @@ private function timezoneSupport(): string
private function maxBulkRows(): int
{
- $raw = getenv('P202_MAX_BULK_ROWS');
- if (is_string($raw) && trim($raw) !== '') {
- $parsed = (int)$raw;
- if ($parsed > 0) {
- return min(5000, $parsed);
- }
- }
- return 500;
+ // Advertise exactly what the bulk endpoint enforces.
+ return \Api\V3\Controller::configuredMaxBulkRows();
}
/**
diff --git a/api/v3/Controllers/ClicksController.php b/api/v3/Controllers/ClicksController.php
index 15cd7ae5..d2b58adc 100644
--- a/api/v3/Controllers/ClicksController.php
+++ b/api/v3/Controllers/ClicksController.php
@@ -4,11 +4,13 @@
namespace Api\V3\Controllers;
-use Api\V3\Exception\DatabaseException;
use Api\V3\Exception\NotFoundException;
+use Api\V3\Support\StatementHelpers;
class ClicksController
{
+ use StatementHelpers;
+
public function __construct(private readonly \mysqli $db, private readonly int $userId)
{
}
@@ -151,31 +153,4 @@ public function get(int $id): array
return ['data' => $row];
}
-
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance in scope; routing through $this->conn would self-recurse)
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse)
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Controllers/ConversionsController.php b/api/v3/Controllers/ConversionsController.php
index 56caa98a..c774ea87 100644
--- a/api/v3/Controllers/ConversionsController.php
+++ b/api/v3/Controllers/ConversionsController.php
@@ -8,9 +8,12 @@
use Api\V3\Exception\NotFoundException;
use Api\V3\Exception\WriteCommittedException;
use Api\V3\Exception\ValidationException;
+use Api\V3\Support\StatementHelpers;
class ConversionsController
{
+ use StatementHelpers;
+
public function __construct(private readonly \mysqli $db, private readonly int $userId)
{
}
@@ -214,31 +217,4 @@ public function delete(int $id): void
throw new DatabaseException('Delete failed: ' . $e->getMessage(), $e);
}
}
-
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance; routing through $this->conn would self-recurse)
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse)
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Controllers/LtvController.php b/api/v3/Controllers/LtvController.php
index ffd34735..3199134b 100644
--- a/api/v3/Controllers/LtvController.php
+++ b/api/v3/Controllers/LtvController.php
@@ -845,7 +845,11 @@ private function query(array $params): LtvQuery
'last7' => [$now - (7 * 86400), $now],
'last30' => [$now - (30 * 86400), $now],
'last90' => [$now - (90 * 86400), $now],
- default => [null, $now],
+ // A typo like period=last7d must not silently mean "all time".
+ default => throw new ValidationException(
+ 'Invalid period',
+ ['period' => 'Valid: today, yesterday, last7, last30, last90']
+ ),
};
}
diff --git a/api/v3/Controllers/ReportsController.php b/api/v3/Controllers/ReportsController.php
index e7ff6216..b9f12957 100644
--- a/api/v3/Controllers/ReportsController.php
+++ b/api/v3/Controllers/ReportsController.php
@@ -6,9 +6,12 @@
use Api\V3\Exception\DatabaseException;
use Api\V3\Exception\ValidationException;
+use Api\V3\Support\StatementHelpers;
class ReportsController
{
+ use StatementHelpers;
+
private const array BREAKDOWNS = [
'campaign' => ['table' => '202_aff_campaigns', 'id' => 'aff_campaign_id', 'name' => 'aff_campaign_name', 'de_id' => 'aff_campaign_id'],
'aff_network' => ['table' => '202_aff_networks', 'id' => 'aff_network_id', 'name' => 'aff_network_name', 'de_id' => 'aff_network_id'],
@@ -437,13 +440,17 @@ private function applyTimeFilters(array $params, array &$where, array &$binds, s
if (!empty($params['period'])) {
$now = time();
$todayStart = strtotime('today midnight');
- [$from, $to] = match ($params['period']) {
+ [$from, $to] = match ((string)$params['period']) {
'today' => [$todayStart, $now],
'yesterday' => [$todayStart - 86400, $todayStart - 1],
'last7' => [$now - (7 * 86400), $now],
'last30' => [$now - (30 * 86400), $now],
'last90' => [$now - (90 * 86400), $now],
- default => [0, $now],
+ // A typo like period=last7d must not silently mean "all time".
+ default => throw new ValidationException(
+ 'Invalid period',
+ ['period' => 'Valid: today, yesterday, last7, last30, last90']
+ ),
};
$where[] = 'de.click_time >= ?';
$binds[] = $from;
@@ -465,15 +472,6 @@ private function applyEntityFilters(array $params, array &$where, array &$binds,
}
}
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
private function resolveUserTimezone(): string
{
$stmt = $this->prepare('SELECT user_timezone FROM 202_users WHERE user_id = ? LIMIT 1');
@@ -538,22 +536,4 @@ private function sortPartRows(array &$rows, string $keyName, string $sortBy, str
return ((int)$a[$keyName]) <=> ((int)$b[$keyName]);
});
}
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized ref-safe bind wrapper (no Connection instance; routing through $this->conn would self-recurse)
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall — this IS the centralized checked-execute wrapper (no Connection instance; routing through $this->conn would self-recurse)
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Controllers/RotatorsController.php b/api/v3/Controllers/RotatorsController.php
index f1fd46e0..13466454 100644
--- a/api/v3/Controllers/RotatorsController.php
+++ b/api/v3/Controllers/RotatorsController.php
@@ -8,9 +8,12 @@
use Api\V3\Exception\NotFoundException;
use Api\V3\Exception\WriteCommittedException;
use Api\V3\Exception\ValidationException;
+use Api\V3\Support\StatementHelpers;
class RotatorsController
{
+ use StatementHelpers;
+
public function __construct(private readonly \mysqli $db, private readonly int $userId)
{
}
@@ -93,6 +96,34 @@ public function get(int $id): array
return ['data' => $row];
}
+ /**
+ * Pick an unused public_id. 202_rotators has no UNIQUE key on the column,
+ * so this is best-effort: it removes deliberate collisions and makes random
+ * ones vanishingly unlikely.
+ */
+ private function publicIdIsFree(int $candidate): bool
+ {
+ $stmt = $this->prepare('SELECT id FROM 202_rotators WHERE public_id = ? LIMIT 1');
+ $this->bind($stmt, 'i', $candidate);
+ $this->execute($stmt, 'Public id lookup failed');
+ $taken = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+
+ return $taken === null || $taken === false;
+ }
+
+ private function generatePublicId(): int
+ {
+ for ($attempt = 0; $attempt < 10; $attempt++) {
+ $candidate = random_int(100_000, 9_999_999);
+ if ($this->publicIdIsFree($candidate)) {
+ return $candidate;
+ }
+ }
+
+ throw new DatabaseException('Unable to allocate a unique rotator public id');
+ }
+
public function create(array $payload): array
{
$name = trim((string)($payload['name'] ?? ''));
@@ -103,9 +134,29 @@ public function create(array $payload): array
$defaultUrl = (string)($payload['default_url'] ?? '');
$defaultCampaign = (int)($payload['default_campaign'] ?? 0);
$defaultLp = (int)($payload['default_lp'] ?? 0);
- $publicId = isset($payload['public_id']) && (int)$payload['public_id'] > 0
- ? (int)$payload['public_id']
- : random_int(100_000, 9_999_999);
+ // public_id is the handle offrtr.php/rtr.php resolve for ANY visitor with
+ // no user scoping, and 202_rotators has no unique key on it — so a
+ // caller-chosen value that is ALREADY TAKEN would route another user's
+ // clicks to this rotator (the lookup is then memcached). An integrity
+ // bug within the install, not cross-install.
+ //
+ // The danger is collision, not caller choice, so honour a supplied
+ // public_id when it is free and fall back to a generated one otherwise.
+ // Rejecting it outright broke `p202 sync`: rotators are matched between
+ // installs by public_id, so a server-assigned value meant the target
+ // never matched the source — every run re-created every rotator, and
+ // remapping trackers' rotator_id failed outright with "unresolvable
+ // target foreign key".
+ $publicId = 0;
+ if (isset($payload['public_id']) && $payload['public_id'] !== '') {
+ $requested = (int)$payload['public_id'];
+ if ($requested > 0 && $this->publicIdIsFree($requested)) {
+ $publicId = $requested;
+ }
+ }
+ if ($publicId === 0) {
+ $publicId = $this->generatePublicId();
+ }
$stmt = $this->prepare('INSERT INTO 202_rotators (public_id, user_id, name, default_url, default_campaign, default_lp) VALUES (?, ?, ?, ?, ?, ?)');
$this->bind($stmt, 'iissii', $publicId, $this->userId, $name, $defaultUrl, $defaultCampaign, $defaultLp);
@@ -125,6 +176,11 @@ public function update(int $id, array $payload): array
{
$this->get($id);
+ // create() rejects empty names; update must too.
+ if (array_key_exists('name', $payload) && trim((string)$payload['name']) === '') {
+ throw new ValidationException('name cannot be empty', ['name' => 'Cannot be empty']);
+ }
+
$sets = [];
$binds = [];
$types = '';
@@ -186,8 +242,7 @@ public function delete(int $id): void
{
$this->get($id);
- $this->db->begin_transaction();
- try {
+ $this->transaction(function () use ($id): void {
$stmt = $this->prepare('DELETE FROM 202_rotator_rules_criteria WHERE rotator_id = ?');
$this->bind($stmt, 'i', $id);
$this->execute($stmt, 'Delete criteria failed');
@@ -207,12 +262,7 @@ public function delete(int $id): void
$this->bind($stmt, 'ii', $id, $this->userId);
$this->execute($stmt, 'Delete rotator failed');
$stmt->close();
-
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
+ });
}
public function listRules(int $rotatorId): array
@@ -233,8 +283,7 @@ public function createRule(int $rotatorId, array $payload): array
$splittest = (int)($payload['splittest'] ?? 0);
$status = (int)($payload['status'] ?? 1);
- $this->db->begin_transaction();
- try {
+ $this->transaction(function () use ($payload, $rotatorId, $ruleName, $splittest, $status): void {
$stmt = $this->prepare('INSERT INTO 202_rotator_rules (rotator_id, rule_name, splittest, status) VALUES (?, ?, ?, ?)');
$this->bind($stmt, 'isii', $rotatorId, $ruleName, $splittest, $status);
$this->execute($stmt, 'Failed to create rule');
@@ -244,6 +293,9 @@ public function createRule(int $rotatorId, array $payload): array
if (!empty($payload['criteria']) && is_array($payload['criteria'])) {
$insertCriteria = $this->prepare('INSERT INTO 202_rotator_rules_criteria (rotator_id, rule_id, type, statement, value) VALUES (?, ?, ?, ?, ?)');
foreach ($payload['criteria'] as $c) {
+ if (!is_array($c)) {
+ throw new ValidationException('Each criterion must be an object', ['criteria' => 'Scalar entries are not valid criteria']);
+ }
$cType = (string)($c['type'] ?? '');
$cStatement = (string)($c['statement'] ?? '');
$cValue = (string)($c['value'] ?? '');
@@ -256,6 +308,9 @@ public function createRule(int $rotatorId, array $payload): array
if (!empty($payload['redirects']) && is_array($payload['redirects'])) {
$insertRedirect = $this->prepare('INSERT INTO 202_rotator_rules_redirects (rule_id, redirect_url, redirect_campaign, redirect_lp, weight, name) VALUES (?, ?, ?, ?, ?, ?)');
foreach ($payload['redirects'] as $r) {
+ if (!is_array($r)) {
+ throw new ValidationException('Each redirect must be an object', ['redirects' => 'Scalar entries are not valid redirects']);
+ }
$rUrl = (string)($r['redirect_url'] ?? '');
$rCampaign = (int)($r['redirect_campaign'] ?? 0);
$rLp = (int)($r['redirect_lp'] ?? 0);
@@ -266,12 +321,7 @@ public function createRule(int $rotatorId, array $payload): array
}
$insertRedirect->close();
}
-
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
+ });
// Committed: the rule and its redirects exist.
try {
@@ -338,8 +388,7 @@ public function updateRule(int $rotatorId, int $ruleId, array $payload): array
throw new ValidationException('No fields to update');
}
- $this->db->begin_transaction();
- try {
+ $this->transaction(function () use ($binds, $hasCriteria, $hasRedirects, $payload, $rotatorId, $ruleId, $setParts, $types): void {
if (!empty($setParts)) {
$binds[] = $ruleId;
$types .= 'i';
@@ -396,12 +445,7 @@ public function updateRule(int $rotatorId, int $ruleId, array $payload): array
$insertRedirect->close();
}
}
-
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
+ });
return $this->get($rotatorId);
}
@@ -455,8 +499,7 @@ public function deleteRule(int $rotatorId, int $ruleId): void
throw new NotFoundException('Rule not found for rotator');
}
- $this->db->begin_transaction();
- try {
+ $this->transaction(function () use ($rotatorId, $ruleId): void {
$stmt = $this->prepare('DELETE FROM 202_rotator_rules_criteria WHERE rule_id = ?');
$this->bind($stmt, 'i', $ruleId);
$this->execute($stmt, 'Delete criteria failed');
@@ -471,38 +514,6 @@ public function deleteRule(int $rotatorId, int $ruleId): void
$this->bind($stmt, 'ii', $ruleId, $rotatorId);
$this->execute($stmt, 'Delete rule failed');
$stmt->close();
-
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
- }
-
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line -- this IS the ref-safe wrapper; no Connection in scope
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line -- this IS the checked-execution wrapper; no Connection in scope
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
+ });
}
}
diff --git a/api/v3/Controllers/SyncController.php b/api/v3/Controllers/SyncController.php
index 16eda3dc..6c475091 100644
--- a/api/v3/Controllers/SyncController.php
+++ b/api/v3/Controllers/SyncController.php
@@ -14,7 +14,7 @@ class SyncController
{
private readonly SyncEngine $engine;
- public function __construct(\mysqli $db, private readonly int $userId, private readonly ?ServerStateStore $store = new ServerStateStore(), ?SyncEngine $engine = null)
+ public function __construct(\mysqli $db, private readonly int $userId, private readonly ServerStateStore $store = new ServerStateStore(), ?SyncEngine $engine = null)
{
if ($db->connect_errno !== 0) {
throw new DatabaseException('Database connection unavailable for sync controller');
@@ -324,7 +324,7 @@ private function runJobInternal(string $jobId): array
$entity = trim((string)($request['entity'] ?? 'all'));
$options = is_array($request['options'] ?? null) ? $request['options'] : [];
- $pairKey = sha1(strtolower((string)($source['url'] ?? '')) . '|' . strtolower((string)($target['url'] ?? '')));
+ $pairKey = SyncEngine::pairKeyFor($source, $target);
$manifest = $this->store->loadSyncManifest($pairKey);
if (!empty($options['incremental']) && empty($options['updated_since']) && !empty($manifest['last_sync_epoch'])) {
$options['updated_since'] = (string)((int)$manifest['last_sync_epoch']);
@@ -352,6 +352,12 @@ function (string $level, string $message, array $data = []) use ($jobId): void {
$job['results'] = $results;
$job['error'] = null;
$job['next_run_at'] = null;
+ // Re-read the persisted flag: a cancel may have landed while
+ // execute() was running and our in-memory copy is stale.
+ $fresh = $this->store->getJob($jobId);
+ if (is_array($fresh) && !empty($fresh['cancel_requested'])) {
+ $job['cancel_requested'] = true;
+ }
if ((bool)($job['cancel_requested'] ?? false)) {
$job['status'] = 'cancelled';
$this->store->incrementMetric('jobs_cancelled', 1);
@@ -472,7 +478,7 @@ private function validatePruneToken(array $source, array $target, array $options
throw new ValidationException('Prune confirmation token required', ['confirmation_token' => 'Required when prune=true']);
}
- $pairKey = sha1(strtolower((string)$source['url']) . '|' . strtolower((string)$target['url']));
+ $pairKey = SyncEngine::pairKeyFor($source, $target);
if (!$this->store->validatePruneToken($token, $pairKey)) {
throw new ValidationException('Invalid prune confirmation token', ['confirmation_token' => 'Token is invalid or expired']);
}
diff --git a/api/v3/Controllers/SystemController.php b/api/v3/Controllers/SystemController.php
index 6fb908f9..cce81967 100644
--- a/api/v3/Controllers/SystemController.php
+++ b/api/v3/Controllers/SystemController.php
@@ -6,9 +6,12 @@
use Api\V3\Exception\DatabaseException;
use Api\V3\Support\ServerStateStore;
+use Api\V3\Support\StatementHelpers;
class SystemController
{
+ use StatementHelpers;
+
public function __construct(private readonly \mysqli $db)
{
}
@@ -70,10 +73,13 @@ public function dbStats(): array
}
$dbName = (string)$dbRow['db'];
+ // One prepared statement re-bound per table, instead of re-preparing
+ // the identical query on every iteration. (bind/execute close the
+ // statement themselves before throwing, so no finally-close here.)
+ $stmt = $this->prepare(
+ "SELECT TABLE_ROWS as cnt FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?"
+ );
foreach ($tables as $table => $label) {
- $stmt = $this->prepare(
- "SELECT TABLE_ROWS as cnt FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?"
- );
$this->bind($stmt, 'ss', $dbName, $table);
$this->execute($stmt, 'Stats query failed');
$result = $stmt->get_result();
@@ -82,9 +88,9 @@ public function dbStats(): array
throw new DatabaseException('Stats query failed');
}
$row = $result->fetch_assoc();
- $stmt->close();
$stats[] = ['table' => $table, 'label' => $label, 'rows_estimate' => (int)($row['cnt'] ?? 0)];
}
+ $stmt->close();
$result = $this->db->query(
"SELECT SUM(data_length + index_length) as size
@@ -246,33 +252,6 @@ public function metrics(): array
];
}
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line -- local ref-safe wrapper
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line -- local checked-execution wrapper
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
-
private function intEnv(string $name, int $default): int
{
$raw = getenv($name);
diff --git a/api/v3/Controllers/UsersController.php b/api/v3/Controllers/UsersController.php
index cfb911cf..5e5ad2a6 100644
--- a/api/v3/Controllers/UsersController.php
+++ b/api/v3/Controllers/UsersController.php
@@ -9,9 +9,12 @@
use Api\V3\Exception\NotFoundException;
use Api\V3\Exception\WriteCommittedException;
use Api\V3\Exception\ValidationException;
+use Api\V3\Support\StatementHelpers;
class UsersController
{
+ use StatementHelpers;
+
public function __construct(private readonly \mysqli $db)
{
}
@@ -106,8 +109,7 @@ public function create(array $payload): array
$installHash = (string) $hashRow['install_hash'];
}
- $this->db->begin_transaction();
- try {
+ $newId = $this->transaction(function () use ($fname, $lname, $username, $hashedPass, $email, $tz, $now, $active, $installHash): int {
$stmt = $this->prepare(
'INSERT INTO 202_users (user_fname, user_lname, user_name, user_pass, user_email, user_dash_email, user_timezone, user_time_register, user_active, install_hash, user_hash, user_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
@@ -122,11 +124,8 @@ public function create(array $payload): array
$this->execute($stmt, 'Failed to create user preferences');
$stmt->close();
- $this->db->commit();
- } catch (\Throwable $e) {
- $this->db->rollback();
- throw $e;
- }
+ return $newId;
+ });
// Committed: both the user row and its preferences row exist. Only
// the read-back remains, and its failure is not a failed create.
@@ -200,10 +199,19 @@ public function deletePreview(int $id): array
public function delete(int $id): void
{
$this->get($id);
- $stmt = $this->prepare('UPDATE 202_users SET user_deleted = 1 WHERE user_id = ?');
- $this->bind($stmt, 'i', $id);
- $this->execute($stmt, 'Delete failed');
- $stmt->close();
+ $this->transaction(function () use ($id): void {
+ $stmt = $this->prepare('UPDATE 202_users SET user_deleted = 1 WHERE user_id = ?');
+ $this->bind($stmt, 'i', $id);
+ $this->execute($stmt, 'Delete failed');
+ $stmt->close();
+
+ // Deleting a user is an access-revocation event: remove their API
+ // keys so the credentials cannot keep authenticating.
+ $stmt = $this->prepare('DELETE FROM 202_api_keys WHERE user_id = ?');
+ $this->bind($stmt, 'i', $id);
+ $this->execute($stmt, 'API key revocation failed');
+ $stmt->close();
+ });
}
// --- Roles ---
@@ -228,6 +236,19 @@ public function assignRole(int $userId, array $payload): array
throw new ValidationException('role_id is required', ['role_id' => 'Must be a positive integer']);
}
+ // Validate BEFORE mutating: 202_user_role has no foreign keys, so an
+ // insert for a nonexistent user/role would persist an orphan grant
+ // that silently becomes live if that user ID is ever created.
+ $this->get($userId);
+ $stmt = $this->prepare('SELECT role_id FROM 202_roles WHERE role_id = ? LIMIT 1');
+ $this->bind($stmt, 'i', $roleId);
+ $this->execute($stmt, 'Role lookup failed');
+ $role = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ if (!$role) {
+ throw new ValidationException('Unknown role_id', ['role_id' => 'Role does not exist']);
+ }
+
$stmt = $this->prepare('INSERT IGNORE INTO 202_user_role (user_id, role_id) VALUES (?, ?)');
$this->bind($stmt, 'ii', $userId, $roleId);
$this->execute($stmt, 'Failed to assign role');
@@ -241,7 +262,12 @@ public function removeRole(int $userId, int $roleId): void
$stmt = $this->prepare('DELETE FROM 202_user_role WHERE user_id = ? AND role_id = ?');
$this->bind($stmt, 'ii', $userId, $roleId);
$this->execute($stmt, 'Failed to remove role');
+ $affected = $stmt->affected_rows;
$stmt->close();
+ if ($affected === 0) {
+ // A revocation that matched nothing must not report success.
+ throw new NotFoundException('Role assignment not found');
+ }
}
// --- API Keys ---
@@ -393,7 +419,14 @@ public function deleteApiKey(int $userId, string $apiKey): void
$stmt = $this->prepare('DELETE FROM 202_api_keys WHERE user_id = ? AND api_key = ?');
$this->bind($stmt, 'is', $userId, $apiKey);
$this->execute($stmt, 'Failed to delete API key');
+ $affected = $stmt->affected_rows;
$stmt->close();
+ if ($affected === 0) {
+ // Callers only ever see masked keys after creation; a mismatched
+ // value deleting zero rows must surface as an error — reporting
+ // 204 here would tell the caller a live credential was revoked.
+ throw new NotFoundException('API key not found');
+ }
}
/**
@@ -509,31 +542,4 @@ public function updatePreferences(int $userId, array $payload): array
return $this->getPreferences($userId);
}
-
- private function prepare(string $sql): \mysqli_stmt
- {
- $stmt = $this->db->prepare($sql);
- if (!$stmt) {
- throw new DatabaseException('Prepare failed');
- }
- return $stmt;
- }
-
- private function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall -- local checked bind wrapper
- if (!$stmt->bind_param($types, ...$values)) {
- $stmt->close();
- throw new DatabaseException('Bind failed');
- }
- }
-
- private function execute(\mysqli_stmt $stmt, string $message): void
- {
- // @phpstan-ignore-next-line prosper202.directStmtCall -- local checked execute wrapper
- if (!$stmt->execute()) {
- $stmt->close();
- throw new DatabaseException($message);
- }
- }
}
diff --git a/api/v3/Support/RemoteApiClient.php b/api/v3/Support/RemoteApiClient.php
index 24b8a209..5dd8e5f0 100644
--- a/api/v3/Support/RemoteApiClient.php
+++ b/api/v3/Support/RemoteApiClient.php
@@ -97,9 +97,13 @@ public function fetchAllRows(string $endpoint, array $extraQuery = []): array
}
$pagination = $resp['pagination'] ?? [];
- $total = (int)($pagination['total'] ?? count($rows));
+ $total = isset($pagination['total']) ? (int)$pagination['total'] : null;
$offset += $limit;
- if ($offset >= $total || count($page) === 0) {
+ if (count($page) < $limit) {
+ // Short page — no more rows regardless of what total claims.
+ break;
+ }
+ if ($total !== null && $offset >= $total) {
break;
}
}
@@ -165,15 +169,28 @@ private function request(string $method, string $path, array $query, ?array $bod
curl_close($ch);
$decoded = json_decode($responseBody, true);
- if (!is_array($decoded)) {
- $decoded = [];
- }
if ($status >= 400) {
- $message = (string)($decoded['message'] ?? ('Remote API error ' . $status));
+ $message = is_array($decoded)
+ ? (string)($decoded['message'] ?? ('Remote API error ' . $status))
+ : 'Remote API error ' . $status;
throw new DatabaseException($message);
}
+ // Redirects are not followed, and a proxy/maintenance page served with
+ // a 2xx status must not masquerade as an empty dataset — callers diff
+ // and prune against these results, so silence here means data loss.
+ if ($status >= 300) {
+ throw new DatabaseException('Remote API returned unexpected status ' . $status);
+ }
+
+ if (trim($responseBody) === '') {
+ return []; // 204-style empty success body
+ }
+ if (!is_array($decoded)) {
+ throw new DatabaseException('Remote API returned invalid JSON (status ' . $status . ')');
+ }
+
return $decoded;
}
}
diff --git a/api/v3/Support/ServerStateStore.php b/api/v3/Support/ServerStateStore.php
index 3b4fc59a..e9f54d83 100644
--- a/api/v3/Support/ServerStateStore.php
+++ b/api/v3/Support/ServerStateStore.php
@@ -78,7 +78,9 @@ public static function canonicalHash(array $payload): string
self::sortPayloadRecursive($payload);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
- return sha1((string)microtime(true));
+ // A random fallback hash would silently break idempotency replay
+ // and incremental-sync diffing (nothing would ever match again).
+ throw new DatabaseException('Failed to encode payload for hashing: ' . json_last_error_msg());
}
return sha1($json);
}
@@ -464,27 +466,33 @@ public function saveJob(array $job): void
if (!isset($job['job_id'])) {
throw new DatabaseException('Job payload missing job_id');
}
- $job['updated_at'] = gmdate('c');
- $this->writeJsonFileAtomic($this->jobPath((string)$job['job_id']), $job);
+ $this->mutateJsonFile($this->jobPath((string)$job['job_id']), [], static function (array $current) use ($job): array {
+ // The worker saves its whole in-memory copy after long-running
+ // work; a cancel flag set concurrently on disk must survive that.
+ if (!empty($current['cancel_requested'])) {
+ $job['cancel_requested'] = true;
+ }
+ $job['updated_at'] = gmdate('c');
+ return $job;
+ });
}
public function appendJobEvent(string $jobId, string $level, string $message, array $data = []): void
{
- $path = $this->jobEventsPath($jobId);
- $events = $this->readJsonFile($path, ['items' => []]);
- $events['items'][] = [
+ $event = [
'event_id' => bin2hex(random_bytes(8)),
'timestamp' => gmdate('c'),
'level' => $level,
'message' => $message,
'data' => $this->sanitizeSensitive($data),
];
-
- if (count($events['items']) > self::DEFAULT_RETENTION) {
- $events['items'] = array_slice($events['items'], -self::DEFAULT_RETENTION);
- }
-
- $this->writeJsonFileAtomic($path, $events);
+ $this->mutateJsonFile($this->jobEventsPath($jobId), ['items' => []], static function (array $events) use ($event): array {
+ $events['items'][] = $event;
+ if (count($events['items']) > self::DEFAULT_RETENTION) {
+ $events['items'] = array_slice($events['items'], -self::DEFAULT_RETENTION);
+ }
+ return $events;
+ });
}
public function listJobEvents(string $jobId, int $offset, int $limit): array
@@ -686,15 +694,14 @@ private function stagedChangesPath(int $userId): string
public function appendAudit(array $record): void
{
- $path = $this->auditPath();
- $audit = $this->readJsonFile($path, ['items' => []]);
- $audit['items'][] = $this->sanitizeSensitive($record);
-
- if (count($audit['items']) > self::DEFAULT_RETENTION) {
- $audit['items'] = array_slice($audit['items'], -self::DEFAULT_RETENTION);
- }
-
- $this->writeJsonFileAtomic($path, $audit);
+ $sanitized = $this->sanitizeSensitive($record);
+ $this->mutateJsonFile($this->auditPath(), ['items' => []], static function (array $audit) use ($sanitized): array {
+ $audit['items'][] = $sanitized;
+ if (count($audit['items']) > self::DEFAULT_RETENTION) {
+ $audit['items'] = array_slice($audit['items'], -self::DEFAULT_RETENTION);
+ }
+ return $audit;
+ });
}
/** @return array> */
@@ -783,35 +790,38 @@ public function acquirePairLock(string $sourceKey, string $targetKey): callable
public function issuePruneToken(string $pairKey, int $ttlSeconds = 600): string
{
$token = bin2hex(random_bytes(16));
- $path = $this->dir('tokens') . '/prune.json';
- $state = $this->readJsonFile($path, ['items' => []]);
- $state['items'][$token] = [
+ $entry = [
'pair_key' => $pairKey,
'expires_at' => time() + $ttlSeconds,
];
- $this->writeJsonFileAtomic($path, $state);
+ $this->mutateJsonFile($this->pruneTokensPath(), ['items' => []], static function (array $state) use ($token, $entry): array {
+ $state['items'][$token] = $entry;
+ return $state;
+ });
return $token;
}
public function validatePruneToken(string $token, string $pairKey): bool
{
- $path = $this->dir('tokens') . '/prune.json';
- $state = $this->readJsonFile($path, ['items' => []]);
- $item = $state['items'][$token] ?? null;
- if (!is_array($item)) {
- return false;
- }
- if ((string)($item['pair_key'] ?? '') !== $pairKey) {
- return false;
- }
- if ((int)($item['expires_at'] ?? 0) < time()) {
- return false;
- }
+ // Check-and-consume must happen under the state lock: the bare
+ // read-then-write version let two concurrent runs both spend the
+ // same single-use token (TOCTOU double-prune).
+ $valid = false;
+ $this->mutateJsonFile($this->pruneTokensPath(), ['items' => []], static function (array $state) use ($token, $pairKey, &$valid): array {
+ $item = $state['items'][$token] ?? null;
+ if (
+ is_array($item)
+ && (string)($item['pair_key'] ?? '') === $pairKey
+ && (int)($item['expires_at'] ?? 0) >= time()
+ ) {
+ $valid = true;
+ unset($state['items'][$token]);
+ }
+ return $state;
+ });
- unset($state['items'][$token]);
- $this->writeJsonFileAtomic($path, $state);
- return true;
+ return $valid;
}
/** @return array> */
@@ -883,11 +893,12 @@ public function saveSyncManifest(string $pairKey, array $manifest): void
public function incrementMetric(string $name, int $delta = 1): void
{
$path = $this->dir('metrics') . '/metrics.json';
- $state = $this->readJsonFile($path, ['counters' => []]);
- $current = (int)($state['counters'][$name] ?? 0);
- $state['counters'][$name] = $current + $delta;
- $state['updated_at'] = gmdate('c');
- $this->writeJsonFileAtomic($path, $state);
+ $this->mutateJsonFile($path, ['counters' => []], static function (array $state) use ($name, $delta): array {
+ $current = (int)($state['counters'][$name] ?? 0);
+ $state['counters'][$name] = $current + $delta;
+ $state['updated_at'] = gmdate('c');
+ return $state;
+ });
}
/** @return array */
@@ -899,10 +910,8 @@ public function metrics(): array
/** @param array $meta */
public function startSpan(string $name, array $meta = []): string
{
- $path = $this->dir('traces') . '/spans.json';
- $state = $this->readJsonFile($path, ['items' => []]);
$id = bin2hex(random_bytes(8));
- $state['items'][] = [
+ $span = [
'span_id' => $id,
'name' => $name,
'status' => 'running',
@@ -913,44 +922,48 @@ public function startSpan(string $name, array $meta = []): string
'ended_at_epoch' => null,
'duration_ms' => null,
];
- if (count($state['items']) > self::DEFAULT_RETENTION) {
- $state['items'] = array_slice($state['items'], -self::DEFAULT_RETENTION);
- }
- $this->writeJsonFileAtomic($path, $state);
+ $this->mutateJsonFile($this->spansPath(), ['items' => []], static function (array $state) use ($span): array {
+ $state['items'][] = $span;
+ if (count($state['items']) > self::DEFAULT_RETENTION) {
+ $state['items'] = array_slice($state['items'], -self::DEFAULT_RETENTION);
+ }
+ return $state;
+ });
return $id;
}
/** @param array $meta */
public function endSpan(string $spanId, string $status = 'ok', array $meta = []): void
{
- $path = $this->dir('traces') . '/spans.json';
- $state = $this->readJsonFile($path, ['items' => []]);
- if (!is_array($state['items'] ?? null)) {
- return;
- }
+ $resultMeta = $this->sanitizeSensitive($meta);
+ $this->mutateJsonFile($this->spansPath(), ['items' => []], static function (array $state) use ($spanId, $status, $resultMeta): array {
+ if (!is_array($state['items'] ?? null)) {
+ return $state;
+ }
- $now = time();
- foreach ($state['items'] as &$item) {
- if ((string)($item['span_id'] ?? '') !== $spanId) {
- continue;
+ $now = time();
+ foreach ($state['items'] as &$item) {
+ if ((string)($item['span_id'] ?? '') !== $spanId) {
+ continue;
+ }
+ $item['status'] = $status;
+ $item['ended_at'] = gmdate('c');
+ $item['ended_at_epoch'] = $now;
+ $started = (int)($item['started_at_epoch'] ?? $now);
+ $item['duration_ms'] = max(0, ($now - $started) * 1000);
+ $item['result_meta'] = $resultMeta;
+ break;
}
- $item['status'] = $status;
- $item['ended_at'] = gmdate('c');
- $item['ended_at_epoch'] = $now;
- $started = (int)($item['started_at_epoch'] ?? $now);
- $item['duration_ms'] = max(0, ($now - $started) * 1000);
- $item['result_meta'] = $this->sanitizeSensitive($meta);
- break;
- }
- unset($item);
+ unset($item);
- $this->writeJsonFileAtomic($path, $state);
+ return $state;
+ });
}
/** @return array> */
public function listSpans(?string $name = null, int $limit = 200): array
{
- $state = $this->readJsonFile($this->dir('traces') . '/spans.json', ['items' => []]);
+ $state = $this->readJsonFile($this->spansPath(), ['items' => []]);
$items = is_array($state['items'] ?? null) ? $state['items'] : [];
$filtered = [];
foreach ($items as $item) {
@@ -979,26 +992,33 @@ public function sanitize(array $payload): array
public function consumeRateLimit(string $bucket, int $maxPerWindow, int $windowSeconds): array
{
$path = $this->dir('rate_limits') . '/' . $this->slug($bucket) . '.json';
- $state = $this->readJsonFile($path, ['window_start' => 0, 'count' => 0]);
-
- $now = time();
- $windowStart = (int)($state['window_start'] ?? 0);
- $count = (int)($state['count'] ?? 0);
- if ($windowStart <= 0 || ($now - $windowStart) >= $windowSeconds) {
- $windowStart = $now;
- $count = 0;
- }
- $count++;
- $allowed = $count <= $maxPerWindow;
- $remaining = max(0, $maxPerWindow - $count);
- $resetAt = $windowStart + $windowSeconds;
+ // Counting must happen under the state lock: with the bare
+ // read-then-write pattern, concurrent requests read the same count
+ // and the limit is systematically undercounted.
+ $allowed = true;
+ $remaining = 0;
+ $resetAt = 0;
+ $this->mutateJsonFile($path, ['window_start' => 0, 'count' => 0], static function (array $state) use ($maxPerWindow, $windowSeconds, &$allowed, &$remaining, &$resetAt): array {
+ $now = time();
+ $windowStart = (int)($state['window_start'] ?? 0);
+ $count = (int)($state['count'] ?? 0);
+ if ($windowStart <= 0 || ($now - $windowStart) >= $windowSeconds) {
+ $windowStart = $now;
+ $count = 0;
+ }
- $this->writeJsonFileAtomic($path, [
- 'window_start' => $windowStart,
- 'count' => $count,
- 'updated_at' => gmdate('c'),
- ]);
+ $count++;
+ $allowed = $count <= $maxPerWindow;
+ $remaining = max(0, $maxPerWindow - $count);
+ $resetAt = $windowStart + $windowSeconds;
+
+ return [
+ 'window_start' => $windowStart,
+ 'count' => $count,
+ 'updated_at' => gmdate('c'),
+ ];
+ });
return [
'allowed' => $allowed,
@@ -1121,6 +1141,16 @@ private function manifestPath(string $pairKey): string
return $this->dir('manifests') . '/' . $this->slug($pairKey) . '.json';
}
+ private function pruneTokensPath(): string
+ {
+ return $this->dir('tokens') . '/prune.json';
+ }
+
+ private function spansPath(): string
+ {
+ return $this->dir('traces') . '/spans.json';
+ }
+
private function dir(string $name): string
{
return $this->baseDir . '/' . $name;
diff --git a/api/v3/Support/StatementHelpers.php b/api/v3/Support/StatementHelpers.php
new file mode 100644
index 00000000..3697a53c
--- /dev/null
+++ b/api/v3/Support/StatementHelpers.php
@@ -0,0 +1,95 @@
+db.
+ */
+trait StatementHelpers
+{
+ protected function prepare(string $sql): \mysqli_stmt
+ {
+ $stmt = $this->db->prepare($sql);
+ if (!$stmt) {
+ throw new DatabaseException('Prepare failed');
+ }
+ return $stmt;
+ }
+
+ protected function bind(\mysqli_stmt $stmt, string $types, mixed ...$values): void
+ {
+ // @phpstan-ignore-next-line this IS the ref-safe bind wrapper (analog of Connection::bind); no $this->conn exists, cannot self-route
+ if (!$stmt->bind_param($types, ...$values)) {
+ $stmt->close();
+ throw new DatabaseException('Bind failed');
+ }
+ }
+
+ protected function execute(\mysqli_stmt $stmt, string $message): void
+ {
+ // @phpstan-ignore-next-line this IS the checked-execute wrapper (analog of Connection::execute); no $this->conn exists, cannot self-route
+ if (!$stmt->execute()) {
+ $stmt->close();
+ throw new DatabaseException($message);
+ }
+ }
+
+ /**
+ * Run $fn inside a transaction: checked begin, checked commit, rollback on
+ * any throwable. This is the only transaction primitive in api/v3 -- the
+ * controllers hand their multi-statement bodies to it as closures rather
+ * than opening a transaction themselves, so there is no second place for a
+ * commit check or a rollback to be forgotten.
+ *
+ * On the return-value checks: which mysqli failure mode applies depends on
+ * the entry point. api/v3/index.php never includes 202-config/connect.php,
+ * so this code runs under PHP's default mysqli_report(ERROR | STRICT) and a
+ * failed begin_transaction()/commit() throws mysqli_sql_exception before
+ * the `if` is reached. connect.php (the UI and cron paths) downgrades that
+ * to STRICT alone, where the same calls return false. The checks are kept
+ * so the helper is correct under both modes -- a trait cannot know which
+ * bootstrap loaded it -- and so the failure has the same DatabaseException
+ * shape as every other helper here.
+ *
+ * @template T
+ * @param callable(): T $fn
+ * @return T
+ */
+ protected function transaction(callable $fn): mixed
+ {
+ // An ignored false here is the worst one: $fn() would run in
+ // autocommit, every statement would land individually, and the
+ // rollback below would have nothing to undo while the caller is told
+ // the operation failed.
+ if (!$this->db->begin_transaction()) {
+ throw new DatabaseException('Could not start transaction: ' . $this->db->error);
+ }
+ try {
+ $result = $fn();
+ if (!$this->db->commit()) {
+ // Thrown, not returned: the catch below is what rolls back, so
+ // a failed commit leaves nothing half-applied on a connection
+ // that may be reused.
+ throw new DatabaseException('Transaction commit failed: ' . $this->db->error);
+ }
+ return $result;
+ } catch (\Throwable $e) {
+ // rollback()'s own result is deliberately unchecked: $e is the root
+ // cause and must reach the caller. A rollback that also fails has
+ // nothing better to report, and replacing $e with it would hide why
+ // the work was abandoned.
+ $this->db->rollback();
+ throw $e;
+ }
+ }
+}
diff --git a/api/v3/Support/SyncEngine.php b/api/v3/Support/SyncEngine.php
index e1eb9aab..0100d8cb 100644
--- a/api/v3/Support/SyncEngine.php
+++ b/api/v3/Support/SyncEngine.php
@@ -211,6 +211,28 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti
$prune = (bool)($options['prune'] ?? false);
$prunePreview = (bool)($options['prune_preview'] ?? false);
+ // Prune decisions must be made against the FULL source key set.
+ // When updated_since filters the fetch above, every unchanged
+ // source record is absent from $sourceData, and diffing the target
+ // against that filtered set would classify the bulk of the target
+ // install as "only in target" and delete it.
+ $pruneSourceKeys = null;
+ if (($prune || $prunePreview) && $updatedSince !== '') {
+ $fullSourceData = $this->fetchPortableData($sourceClient);
+ $fullSourceLookups = $this->buildEntityLookups($fullSourceData);
+ $pruneSourceKeys = [];
+ foreach ($entities as $pruneEntity) {
+ $pruneSourceKeys[$pruneEntity] = [];
+ foreach ($fullSourceData[$pruneEntity] as $fullRow) {
+ $fullKey = $this->naturalKeyForEntity($pruneEntity, $fullRow, $fullSourceLookups);
+ if ($fullKey !== '') {
+ $pruneSourceKeys[$pruneEntity][$fullKey] = true;
+ }
+ }
+ }
+ unset($fullSourceData, $fullSourceLookups);
+ }
+
$results = [];
$mappings = [];
$sourceHashes = [];
@@ -220,6 +242,7 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti
$entitySpan = $this->startTraceSpan('sync.execute.entity', ['entity' => $entity]);
$remapSpan = $this->startTraceSpan('sync.execute.remap', ['entity' => $entity]);
$remapOps = 0;
+ try {
$result = [
'synced' => 0,
'skipped' => 0,
@@ -397,6 +420,7 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti
}
$this->endTraceSpan($remapSpan, 'ok', ['operations' => $remapOps]);
+ $remapSpan = null;
$writeSpan = $this->startTraceSpan('sync.execute.write', ['entity' => $entity]);
$this->endTraceSpan($writeSpan, 'ok', [
'created' => (int)($result['created'] ?? 0),
@@ -408,10 +432,11 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti
$pruneSpan = $this->startTraceSpan('sync.execute.prune', ['entity' => $entity]);
$allow = $this->normalizeEntitySet($options['prune_allowlist'] ?? []);
$deny = $this->normalizeEntitySet($options['prune_denylist'] ?? []);
+ $knownSourceKeys = $pruneSourceKeys !== null ? ($pruneSourceKeys[$entity] ?? []) : $sourceKeys;
foreach ($targetData[$entity] as $targetRow) {
$targetKey = $this->naturalKeyForEntity($entity, $targetRow, $targetLookups);
- if ($targetKey === '' || isset($sourceKeys[$targetKey])) {
+ if ($targetKey === '' || isset($knownSourceKeys[$targetKey])) {
continue;
}
@@ -462,6 +487,13 @@ public function execute(array $sourceProfile, array $targetProfile, string $enti
'synced' => (int)($result['synced'] ?? 0),
'failed' => (int)($result['failed'] ?? 0),
]);
+ $entitySpan = null;
+ } finally {
+ // On the success path both are null; a thrown error must
+ // not leave spans stuck in 'running' forever.
+ $this->endTraceSpan($remapSpan, 'error');
+ $this->endTraceSpan($entitySpan, 'error');
+ }
}
$traceMeta = ['entities' => count($entities)];
@@ -516,9 +548,26 @@ private function syncRotatorRules(
array $sourceLookups,
array $targetLookups
): void {
+ $rules = $this->fetchSourceRotatorRules($sourceClient, $sourceRotatorId);
+ $this->postRotatorRules($targetClient, $targetRotatorId, $rules, $sourceLookups, $targetLookups);
+ }
+
+ /** @return array> */
+ private function fetchSourceRotatorRules(RemoteApiClient $sourceClient, string $sourceRotatorId): array
+ {
$sourceRotator = $sourceClient->get('rotators/' . $sourceRotatorId);
$rules = $sourceRotator['data']['rules'] ?? [];
+ return is_array($rules) ? $rules : [];
+ }
+ /** @param array> $rules */
+ private function postRotatorRules(
+ RemoteApiClient $targetClient,
+ string $targetRotatorId,
+ array $rules,
+ array $sourceLookups,
+ array $targetLookups
+ ): void {
foreach ($rules as $rule) {
$rulePayload = [
'rule_name' => $rule['rule_name'] ?? '',
@@ -586,8 +635,16 @@ private function resyncRotatorRules(
array $sourceLookups,
array $targetLookups
): void {
+ // Fetch the source rules BEFORE deleting anything on the target: if the
+ // source fetch fails we abort with the target rotator's rules intact,
+ // instead of stripping them and having nothing to recreate.
+ $sourceRules = $this->fetchSourceRotatorRules($sourceClient, $sourceRotatorId);
+
$targetRotator = $targetClient->get('rotators/' . $targetRotatorId);
$targetRules = $targetRotator['data']['rules'] ?? [];
+ if (!is_array($targetRules)) {
+ $targetRules = [];
+ }
foreach ($targetRules as $rule) {
if (!is_array($rule)) {
continue;
@@ -599,14 +656,7 @@ private function resyncRotatorRules(
$targetClient->delete('rotators/' . $targetRotatorId . '/rules/' . $ruleId);
}
- $this->syncRotatorRules(
- $sourceClient,
- $targetClient,
- $sourceRotatorId,
- $targetRotatorId,
- $sourceLookups,
- $targetLookups
- );
+ $this->postRotatorRules($targetClient, $targetRotatorId, $sourceRules, $sourceLookups, $targetLookups);
}
protected function buildClients(array $sourceProfile, array $targetProfile): array
@@ -651,8 +701,13 @@ protected function fetchPortableData(RemoteApiClient $client, array $query = [])
$detailData = is_array($detail['data'] ?? null) ? $detail['data'] : [];
$rules = $detailData['rules'] ?? [];
$row['rules'] = is_array($rules) ? $rules : [];
- } catch (\Throwable) {
- $row['rules'] = [];
+ } catch (\Throwable $e) {
+ // Do not map a failed detail fetch to "no rules": the
+ // diff would see a rule-less rotator and a force_update
+ // run would delete every rule on the other side.
+ throw new DatabaseException(
+ 'Failed to fetch rotator ' . $rotatorId . ' detail: ' . $e->getMessage()
+ );
}
$enriched[] = $row;
}
@@ -1176,7 +1231,9 @@ private function comparableHash(array $row): string
$this->sortRecursive($copy);
$json = json_encode($copy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
- return sha1((string)microtime(true));
+ // A random fallback would silently defeat incremental-sync
+ // hash matching (every run re-syncs every record, forever).
+ throw new DatabaseException('Failed to encode record for hashing: ' . json_last_error_msg());
}
return sha1($json);
}
@@ -1286,11 +1343,22 @@ private function envFlagEnabled(string $name, bool $default): bool
return !in_array($value, ['0', 'false', 'no', 'off', 'disabled'], true);
}
- private function pairKey(array $sourceProfile, array $targetProfile): string
+ /**
+ * Single source of truth for the source/target pair key. Prune tokens,
+ * manifests, and audit records must all agree on this formula — an
+ * independent copy that drifts silently breaks token validation and
+ * incremental manifests.
+ */
+ public static function pairKeyFor(array $sourceProfile, array $targetProfile): string
{
return sha1(strtolower((string)($sourceProfile['url'] ?? '')) . '|' . strtolower((string)($targetProfile['url'] ?? '')));
}
+ private function pairKey(array $sourceProfile, array $targetProfile): string
+ {
+ return self::pairKeyFor($sourceProfile, $targetProfile);
+ }
+
private function profileLabel(array $profile): string
{
$name = trim((string)($profile['name'] ?? ''));
diff --git a/api/v3/index.php b/api/v3/index.php
index 0e27374c..dfb48f8b 100644
--- a/api/v3/index.php
+++ b/api/v3/index.php
@@ -66,7 +66,12 @@
$payload = [];
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
- $raw = file_get_contents('php://input', false, null, 0, 1_048_576); // 1 MB limit
+ $maxBody = 1_048_576; // 1 MB limit
+ $raw = file_get_contents('php://input', false, null, 0, $maxBody + 1);
+ if ($raw !== false && strlen($raw) > $maxBody) {
+ Bootstrap::errorResponse('Request body too large', 413, ['max_bytes' => $maxBody]);
+ exit;
+ }
if ($raw !== '' && $raw !== false) {
$payload = json_decode($raw, true);
if ($payload === null && json_last_error() !== JSON_ERROR_NONE) {
@@ -85,7 +90,7 @@
}
}
-$requestedVersion = strtolower(trim((string)($headers['X-P202-API-Version'] ?? $headers['x-p202-api-version'] ?? '')));
+$requestedVersion = strtolower((string)RequestContext::header('x-p202-api-version', ''));
if ($requestedVersion !== '' && !in_array($requestedVersion, ['v3', '3'], true)) {
Bootstrap::errorResponse(
'Unsupported API version',
diff --git a/cli/ApiClient.php b/cli/ApiClient.php
index b25b556d..542e6e24 100644
--- a/cli/ApiClient.php
+++ b/cli/ApiClient.php
@@ -119,6 +119,12 @@ private function request(string $method, string $path, array $params = [], array
throw new ApiException($msg, $httpCode, $data);
}
+ if ($response !== '' && !is_array($decoded)) {
+ // A scalar body on success must not silently render as an
+ // empty result — surface what the server actually sent.
+ throw new \RuntimeException('Unexpected non-object JSON response from server: ' . substr($response, 0, 200));
+ }
+
return $data;
}
}
diff --git a/cli/Application.php b/cli/Application.php
index 4b6fe1af..03d71a71 100644
--- a/cli/Application.php
+++ b/cli/Application.php
@@ -116,7 +116,9 @@ private function registerCrudEntities(): void
'aff_campaign_cloaking' => 'Enable cloaking (0|1)',
'aff_campaign_rotate' => 'Enable URL rotation (0|1)',
],
- 'required' => ['aff_campaign_name', 'aff_campaign_url'],
+ // Must match CampaignsController::fields() required flags, or
+ // client-side validation passes and the server 422s anyway.
+ 'required' => ['aff_campaign_name', 'aff_campaign_url', 'aff_campaign_payout', 'aff_network_id'],
'listParams' => ['filter[aff_network_id]' => 'Filter by affiliate network'],
],
[
diff --git a/cli/Commands/AttributionModelCreateCommand.php b/cli/Commands/AttributionModelCreateCommand.php
index a0cbfc3d..7ea48984 100644
--- a/cli/Commands/AttributionModelCreateCommand.php
+++ b/cli/Commands/AttributionModelCreateCommand.php
@@ -41,16 +41,9 @@ protected function handle(InputInterface $input, OutputInterface $output): int
'is_default' => (int)$input->getOption('is_default'),
];
- $weightingConfig = $input->getOption('weighting_config');
+ $weightingConfig = $this->decodeJsonOption($input, 'weighting_config');
if ($weightingConfig !== null) {
- $decodedConfig = json_decode((string)$weightingConfig, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- $output->writeln(
- sprintf('Invalid --weighting_config JSON: %s', json_last_error_msg())
- );
- return Command::FAILURE;
- }
- $body['weighting_config'] = $decodedConfig;
+ $body['weighting_config'] = $weightingConfig;
}
$result = $this->client()->post('attribution/models', $body);
diff --git a/cli/Commands/AttributionModelDeleteCommand.php b/cli/Commands/AttributionModelDeleteCommand.php
index 4329b8c4..8f4736c5 100644
--- a/cli/Commands/AttributionModelDeleteCommand.php
+++ b/cli/Commands/AttributionModelDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class AttributionModelDeleteCommand extends BaseCommand
{
@@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
{
$id = $input->getArgument('id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete attribution model %s? [y/N] ', $id),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete attribution model %s', $id))) {
+ return Command::SUCCESS;
}
$this->client()->delete('attribution/models/' . $id);
diff --git a/cli/Commands/AttributionModelUpdateCommand.php b/cli/Commands/AttributionModelUpdateCommand.php
index 2e17edc3..41986d72 100644
--- a/cli/Commands/AttributionModelUpdateCommand.php
+++ b/cli/Commands/AttributionModelUpdateCommand.php
@@ -29,23 +29,10 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $body = [];
- foreach (['model_name', 'model_type', 'is_active', 'is_default'] as $f) {
- $val = $input->getOption($f);
- if ($val !== null) {
- $body[$f] = $val;
- }
- }
- $weightingConfig = $input->getOption('weighting_config');
+ $body = $this->collectOptions($input, ['model_name', 'model_type', 'is_active', 'is_default']);
+ $weightingConfig = $this->decodeJsonOption($input, 'weighting_config');
if ($weightingConfig !== null) {
- $decodedConfig = json_decode((string)$weightingConfig, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- $output->writeln(
- sprintf('Invalid --weighting_config JSON: %s', json_last_error_msg())
- );
- return Command::FAILURE;
- }
- $body['weighting_config'] = $decodedConfig;
+ $body['weighting_config'] = $weightingConfig;
}
if (empty($body)) {
diff --git a/cli/Commands/BaseCommand.php b/cli/Commands/BaseCommand.php
index fbcf97fd..8051d513 100644
--- a/cli/Commands/BaseCommand.php
+++ b/cli/Commands/BaseCommand.php
@@ -12,6 +12,8 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+use Symfony\Component\Console\Question\Question;
/**
* Base command that provides shared infrastructure:
@@ -48,6 +50,83 @@ protected function render(OutputInterface $output, array $data, InputInterface $
Formatter::output($output, $data, $this->isJson($input));
}
+ /**
+ * Shared confirm-or-force gate for destructive commands.
+ *
+ * Validates client configuration BEFORE prompting, so an unconfigured
+ * user is never asked to confirm a deletion the tool cannot perform.
+ * $action is the verb phrase, e.g. "delete campaign #3".
+ */
+ protected function confirmDestructive(InputInterface $input, OutputInterface $output, string $action): bool
+ {
+ $this->client();
+
+ if ($input->hasOption('force') && $input->getOption('force')) {
+ return true;
+ }
+
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Are you sure you want to {$action}? [y/N] ", false);
+ if (!$helper->ask($input, $output, $question)) {
+ $output->writeln('Cancelled.');
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Collect the named options that were explicitly provided (non-null).
+ */
+ protected function collectOptions(InputInterface $input, array $names): array
+ {
+ $params = [];
+ foreach ($names as $name) {
+ if ($input->hasOption($name)) {
+ $value = $input->getOption($name);
+ if ($value !== null) {
+ $params[$name] = $value;
+ }
+ }
+ }
+ return $params;
+ }
+
+ /**
+ * Decode a JSON option strictly. Returns null when the option was not
+ * provided; malformed JSON or a scalar (which the server would silently
+ * drop) is an explicit error, never silently discarded.
+ */
+ protected function decodeJsonOption(InputInterface $input, string $name): ?array
+ {
+ $raw = $input->getOption($name);
+ if ($raw === null) {
+ return null;
+ }
+
+ $decoded = json_decode((string)$raw, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new \RuntimeException("Invalid JSON in --{$name}: " . json_last_error_msg());
+ }
+ if (!is_array($decoded)) {
+ throw new \RuntimeException("--{$name} must be a JSON array or object");
+ }
+ return $decoded;
+ }
+
+ /**
+ * Prompt for a secret without echoing it (keeps credentials out of shell
+ * history and ps output). Returns null if nothing was entered.
+ */
+ protected function promptHiddenSecret(InputInterface $input, OutputInterface $output, string $prompt): ?string
+ {
+ $helper = $this->getHelper('question');
+ $question = new Question($prompt);
+ $question->setHidden(true);
+ $question->setHiddenFallback(false);
+ $value = $helper->ask($input, $output, $question);
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
/**
* Override Symfony's execute to wrap in error handling.
* Subclasses implement handle() instead of execute().
diff --git a/cli/Commands/ConfigSetKeyCommand.php b/cli/Commands/ConfigSetKeyCommand.php
index a4432a66..7bc537ab 100644
--- a/cli/Commands/ConfigSetKeyCommand.php
+++ b/cli/Commands/ConfigSetKeyCommand.php
@@ -10,20 +10,38 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-class ConfigSetKeyCommand extends Command
+class ConfigSetKeyCommand extends BaseCommand
{
protected static $defaultName = 'config:set-key';
+ #[\Override]
protected function configure(): void
{
+ parent::configure();
$this->setDescription('Set the API key for authentication')
- ->addArgument('key', InputArgument::REQUIRED, 'Your Prosper202 API key');
+ ->addArgument(
+ 'key',
+ InputArgument::OPTIONAL,
+ 'Your Prosper202 API key (omit to be prompted without echoing — keeps the key out of shell history)'
+ );
}
- protected function execute(InputInterface $input, OutputInterface $output): int
+ protected function handle(InputInterface $input, OutputInterface $output): int
{
+ $key = $input->getArgument('key');
+ if ($key === null || $key === '') {
+ // Same treatment passwords get in user:create — an API key is a
+ // bearer credential and should not have to pass through shell
+ // history or ps output.
+ $key = $this->promptHiddenSecret($input, $output, 'API key (hidden): ');
+ if ($key === null) {
+ $output->writeln('API key is required');
+ return Command::FAILURE;
+ }
+ }
+
$config = new Config();
- $config->set('api_key', $input->getArgument('key'));
+ $config->set('api_key', $key);
$config->save();
$output->writeln('API key saved.');
return Command::SUCCESS;
diff --git a/cli/Commands/ConversionDeleteCommand.php b/cli/Commands/ConversionDeleteCommand.php
index 7f70dde7..2ab206eb 100644
--- a/cli/Commands/ConversionDeleteCommand.php
+++ b/cli/Commands/ConversionDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class ConversionDeleteCommand extends BaseCommand
{
@@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
{
$id = $input->getArgument('id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete conversion %s? [y/N] ', $id),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete conversion %s', $id))) {
+ return Command::SUCCESS;
}
$this->client()->delete('conversions/' . $id);
diff --git a/cli/Commands/CrudCommands.php b/cli/Commands/CrudCommands.php
index f407c091..404b73f4 100644
--- a/cli/Commands/CrudCommands.php
+++ b/cli/Commands/CrudCommands.php
@@ -13,7 +13,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* Factory for generating standard CRUD commands (list, get, create, update, delete)
@@ -203,16 +202,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
{
$id = $input->getArgument('id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- "Are you sure you want to delete {$this->entity} #{$id}? [y/N] ",
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, "delete {$this->entity} #{$id}")) {
+ return Command::SUCCESS;
}
$this->client()->delete($this->endpoint . '/' . $id);
diff --git a/cli/Commands/LtvBreakdownCommand.php b/cli/Commands/LtvBreakdownCommand.php
index 5af59e24..1fd52f5c 100644
--- a/cli/Commands/LtvBreakdownCommand.php
+++ b/cli/Commands/LtvBreakdownCommand.php
@@ -28,7 +28,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $result = $this->client()->get('ltv/breakdown', LtvSummaryCommand::collectLtvParams($input));
+ $result = $this->client()->get('ltv/breakdown', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS));
$this->render($output, $result, $input);
return Command::SUCCESS;
}
diff --git a/cli/Commands/LtvCustomersCommand.php b/cli/Commands/LtvCustomersCommand.php
index 2de51eb1..bc405cd1 100644
--- a/cli/Commands/LtvCustomersCommand.php
+++ b/cli/Commands/LtvCustomersCommand.php
@@ -35,7 +35,7 @@ protected function handle(InputInterface $input, OutputInterface $output): int
if ($id !== null) {
$result = $this->client()->get('ltv/customers/' . (int) $id, []);
} else {
- $result = $this->client()->get('ltv/customers', LtvSummaryCommand::collectLtvParams($input));
+ $result = $this->client()->get('ltv/customers', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS));
}
$this->render($output, $result, $input);
return Command::SUCCESS;
diff --git a/cli/Commands/LtvPredictCommand.php b/cli/Commands/LtvPredictCommand.php
index 060ad38a..76ff24d7 100644
--- a/cli/Commands/LtvPredictCommand.php
+++ b/cli/Commands/LtvPredictCommand.php
@@ -26,7 +26,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $result = $this->client()->get('ltv/predict', LtvSummaryCommand::collectLtvParams($input));
+ $result = $this->client()->get('ltv/predict', $this->collectOptions($input, LtvSummaryCommand::LTV_PARAMS));
$this->render($output, $result, $input);
return Command::SUCCESS;
}
diff --git a/cli/Commands/LtvSummaryCommand.php b/cli/Commands/LtvSummaryCommand.php
index ce61b283..973bcdaa 100644
--- a/cli/Commands/LtvSummaryCommand.php
+++ b/cli/Commands/LtvSummaryCommand.php
@@ -11,6 +11,9 @@
class LtvSummaryCommand extends BaseCommand
{
+ /** Query options shared by the LTV read commands. */
+ public const array LTV_PARAMS = ['period', 'time_from', 'time_to', 'by', 'sort', 'dir', 'limit', 'offset'];
+
protected static $defaultName = 'ltv:summary';
#[\Override]
@@ -25,22 +28,9 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $result = $this->client()->get('ltv/summary', self::collectLtvParams($input));
+ $result = $this->client()->get('ltv/summary', $this->collectOptions($input, self::LTV_PARAMS));
$this->render($output, $result, $input);
return Command::SUCCESS;
}
- public static function collectLtvParams(InputInterface $input): array
- {
- $params = [];
- foreach (['period', 'time_from', 'time_to', 'by', 'sort', 'dir', 'limit', 'offset'] as $p) {
- if ($input->hasOption($p)) {
- $val = $input->getOption($p);
- if ($val !== null) {
- $params[$p] = $val;
- }
- }
- }
- return $params;
- }
}
diff --git a/cli/Commands/ReportBreakdownCommand.php b/cli/Commands/ReportBreakdownCommand.php
index 4e7bae6f..feb79d1a 100644
--- a/cli/Commands/ReportBreakdownCommand.php
+++ b/cli/Commands/ReportBreakdownCommand.php
@@ -36,7 +36,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $params = ReportSummaryCommand::collectParams($input);
+ $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS);
$params['breakdown'] = $input->getOption('breakdown');
$params['sort'] = $input->getOption('sort');
$params['sort_dir'] = $input->getOption('sort_dir');
diff --git a/cli/Commands/ReportDaypartCommand.php b/cli/Commands/ReportDaypartCommand.php
index 3825f63a..5d6b4ad8 100644
--- a/cli/Commands/ReportDaypartCommand.php
+++ b/cli/Commands/ReportDaypartCommand.php
@@ -33,7 +33,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $params = ReportSummaryCommand::collectParams($input);
+ $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS);
$params['sort'] = (string)$input->getOption('sort');
$params['sort_dir'] = (string)$input->getOption('sort_dir');
diff --git a/cli/Commands/ReportSummaryCommand.php b/cli/Commands/ReportSummaryCommand.php
index 824a500a..f1410de2 100644
--- a/cli/Commands/ReportSummaryCommand.php
+++ b/cli/Commands/ReportSummaryCommand.php
@@ -11,6 +11,9 @@
class ReportSummaryCommand extends BaseCommand
{
+ /** Filter options shared by every report command. */
+ public const array FILTER_PARAMS = ['period', 'time_from', 'time_to', 'aff_campaign_id', 'ppc_account_id', 'aff_network_id', 'ppc_network_id', 'landing_page_id', 'country_id'];
+
protected static $defaultName = 'report:summary';
#[\Override]
@@ -27,23 +30,10 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $params = self::collectParams($input);
+ $params = $this->collectOptions($input, self::FILTER_PARAMS);
$result = $this->client()->get('reports/summary', $params);
$this->render($output, $result, $input);
return Command::SUCCESS;
}
- public static function collectParams(InputInterface $input): array
- {
- $params = [];
- foreach (['period', 'time_from', 'time_to', 'aff_campaign_id', 'ppc_account_id', 'aff_network_id', 'ppc_network_id', 'landing_page_id', 'country_id'] as $p) {
- if ($input->hasOption($p)) {
- $val = $input->getOption($p);
- if ($val !== null) {
- $params[$p] = $val;
- }
- }
- }
- return $params;
- }
}
diff --git a/cli/Commands/ReportTimeseriesCommand.php b/cli/Commands/ReportTimeseriesCommand.php
index 3bdd0709..7ac23386 100644
--- a/cli/Commands/ReportTimeseriesCommand.php
+++ b/cli/Commands/ReportTimeseriesCommand.php
@@ -32,7 +32,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $params = ReportSummaryCommand::collectParams($input);
+ $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS);
$params['interval'] = $input->getOption('interval');
$result = $this->client()->get('reports/timeseries', $params);
diff --git a/cli/Commands/ReportWeekpartCommand.php b/cli/Commands/ReportWeekpartCommand.php
index a1f14e81..c902eeeb 100644
--- a/cli/Commands/ReportWeekpartCommand.php
+++ b/cli/Commands/ReportWeekpartCommand.php
@@ -33,7 +33,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $params = ReportSummaryCommand::collectParams($input);
+ $params = $this->collectOptions($input, ReportSummaryCommand::FILTER_PARAMS);
$params['sort'] = (string)$input->getOption('sort');
$params['sort_dir'] = (string)$input->getOption('sort_dir');
diff --git a/cli/Commands/RotatorDeleteCommand.php b/cli/Commands/RotatorDeleteCommand.php
index e33f91a1..547a11e5 100644
--- a/cli/Commands/RotatorDeleteCommand.php
+++ b/cli/Commands/RotatorDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class RotatorDeleteCommand extends BaseCommand
{
@@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
{
$id = $input->getArgument('id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete rotator %s? [y/N] ', $id),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete rotator %s', $id))) {
+ return Command::SUCCESS;
}
$this->client()->delete('rotators/' . $id);
diff --git a/cli/Commands/RotatorRuleCreateCommand.php b/cli/Commands/RotatorRuleCreateCommand.php
index 954ebadf..cc720712 100644
--- a/cli/Commands/RotatorRuleCreateCommand.php
+++ b/cli/Commands/RotatorRuleCreateCommand.php
@@ -39,19 +39,16 @@ protected function handle(InputInterface $input, OutputInterface $output): int
'splittest' => (int)$input->getOption('splittest'),
];
- if ($input->getOption('criteria_json')) {
- $body['criteria'] = json_decode((string) $input->getOption('criteria_json'), true);
- if ($body['criteria'] === null) {
- $output->writeln('Invalid JSON in --criteria_json');
- return Command::FAILURE;
- }
+ // decodeJsonOption rejects malformed JSON AND scalar values — a scalar
+ // like --criteria_json='"country is US"' would previously be sent to
+ // the server, silently dropped, and the rule created with no criteria.
+ $criteria = $this->decodeJsonOption($input, 'criteria_json');
+ if ($criteria !== null) {
+ $body['criteria'] = $criteria;
}
- if ($input->getOption('redirects_json')) {
- $body['redirects'] = json_decode((string) $input->getOption('redirects_json'), true);
- if ($body['redirects'] === null) {
- $output->writeln('Invalid JSON in --redirects_json');
- return Command::FAILURE;
- }
+ $redirects = $this->decodeJsonOption($input, 'redirects_json');
+ if ($redirects !== null) {
+ $body['redirects'] = $redirects;
}
$result = $this->client()->post('rotators/' . $input->getArgument('rotator_id') . '/rules', $body);
diff --git a/cli/Commands/RotatorRuleDeleteCommand.php b/cli/Commands/RotatorRuleDeleteCommand.php
index 772adc76..847de7d4 100644
--- a/cli/Commands/RotatorRuleDeleteCommand.php
+++ b/cli/Commands/RotatorRuleDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class RotatorRuleDeleteCommand extends BaseCommand
{
@@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
$rotatorId = $input->getArgument('rotator_id');
$ruleId = $input->getArgument('rule_id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete rule %s from rotator %s? [y/N] ', $ruleId, $rotatorId),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete rule %s from rotator %s', $ruleId, $rotatorId))) {
+ return Command::SUCCESS;
}
$this->client()->delete('rotators/' . $rotatorId . '/rules/' . $ruleId);
diff --git a/cli/Commands/RotatorUpdateCommand.php b/cli/Commands/RotatorUpdateCommand.php
index a53ebe03..4a3d1db4 100644
--- a/cli/Commands/RotatorUpdateCommand.php
+++ b/cli/Commands/RotatorUpdateCommand.php
@@ -28,13 +28,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $body = [];
- foreach (['name', 'default_url', 'default_campaign', 'default_lp'] as $f) {
- $val = $input->getOption($f);
- if ($val !== null) {
- $body[$f] = $val;
- }
- }
+ $body = $this->collectOptions($input, ['name', 'default_url', 'default_campaign', 'default_lp']);
if (empty($body)) {
$output->writeln('Provide at least one field to update');
return Command::FAILURE;
diff --git a/cli/Commands/UserApiKeyDeleteCommand.php b/cli/Commands/UserApiKeyDeleteCommand.php
index 32522716..1b1588d5 100644
--- a/cli/Commands/UserApiKeyDeleteCommand.php
+++ b/cli/Commands/UserApiKeyDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class UserApiKeyDeleteCommand extends BaseCommand
{
@@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
$userId = $input->getArgument('user_id');
$apiKey = $input->getArgument('api_key');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete API key %s for user %s? [y/N] ', $apiKey, $userId),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete API key %s for user %s', $apiKey, $userId))) {
+ return Command::SUCCESS;
}
$this->client()->delete('users/' . $userId . '/api-keys/' . $apiKey);
diff --git a/cli/Commands/UserCreateCommand.php b/cli/Commands/UserCreateCommand.php
index 9a73cd74..07877eae 100644
--- a/cli/Commands/UserCreateCommand.php
+++ b/cli/Commands/UserCreateCommand.php
@@ -8,7 +8,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\Question;
class UserCreateCommand extends BaseCommand
{
@@ -48,12 +47,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
// Secure password input: if not provided via --user_pass, prompt interactively.
// This avoids leaking the password into shell history and ps output.
if (empty($body['user_pass'])) {
- $helper = $this->getHelper('question');
- $question = new Question('Password (hidden): ');
- $question->setHidden(true);
- $question->setHiddenFallback(false);
- $password = $helper->ask($input, $output, $question);
- if (!$password) {
+ $password = $this->promptHiddenSecret($input, $output, 'Password (hidden): ');
+ if ($password === null) {
$output->writeln('Password is required');
return Command::FAILURE;
}
diff --git a/cli/Commands/UserDeleteCommand.php b/cli/Commands/UserDeleteCommand.php
index e7a0b326..d8c4ef91 100644
--- a/cli/Commands/UserDeleteCommand.php
+++ b/cli/Commands/UserDeleteCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class UserDeleteCommand extends BaseCommand
{
@@ -28,16 +27,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
{
$id = $input->getArgument('id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to delete user %s? [y/N] ', $id),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('delete user %s', $id))) {
+ return Command::SUCCESS;
}
$this->client()->delete('users/' . $id);
diff --git a/cli/Commands/UserPreferencesUpdateCommand.php b/cli/Commands/UserPreferencesUpdateCommand.php
index f1d41c05..572f4ef6 100644
--- a/cli/Commands/UserPreferencesUpdateCommand.php
+++ b/cli/Commands/UserPreferencesUpdateCommand.php
@@ -29,13 +29,7 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $body = [];
- foreach (['user_tracking_domain', 'user_account_currency', 'user_slack_incoming_webhook', 'user_daily_email', 'ipqs_api_key'] as $f) {
- $val = $input->getOption($f);
- if ($val !== null) {
- $body[$f] = $val;
- }
- }
+ $body = $this->collectOptions($input, ['user_tracking_domain', 'user_account_currency', 'user_slack_incoming_webhook', 'user_daily_email', 'ipqs_api_key']);
if (empty($body)) {
$output->writeln('Provide at least one preference to update');
return Command::FAILURE;
diff --git a/cli/Commands/UserRoleRemoveCommand.php b/cli/Commands/UserRoleRemoveCommand.php
index 52490c40..8f4b19b8 100644
--- a/cli/Commands/UserRoleRemoveCommand.php
+++ b/cli/Commands/UserRoleRemoveCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\ConfirmationQuestion;
class UserRoleRemoveCommand extends BaseCommand
{
@@ -30,16 +29,8 @@ protected function handle(InputInterface $input, OutputInterface $output): int
$userId = $input->getArgument('user_id');
$roleId = $input->getArgument('role_id');
- if (!$input->getOption('force')) {
- $helper = $this->getHelper('question');
- $question = new ConfirmationQuestion(
- sprintf('Are you sure you want to remove role %s from user %s? [y/N] ', $roleId, $userId),
- false
- );
- if (!$helper->ask($input, $output, $question)) {
- $output->writeln('Cancelled.');
- return Command::SUCCESS;
- }
+ if (!$this->confirmDestructive($input, $output, sprintf('remove role %s from user %s', $roleId, $userId))) {
+ return Command::SUCCESS;
}
$this->client()->delete('users/' . $userId . '/roles/' . $roleId);
diff --git a/cli/Commands/UserUpdateCommand.php b/cli/Commands/UserUpdateCommand.php
index d179a7ad..9b02950b 100644
--- a/cli/Commands/UserUpdateCommand.php
+++ b/cli/Commands/UserUpdateCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Question\Question;
class UserUpdateCommand extends BaseCommand
{
@@ -31,24 +30,14 @@ protected function configure(): void
protected function handle(InputInterface $input, OutputInterface $output): int
{
- $body = [];
- foreach (['user_fname', 'user_lname', 'user_email', 'user_timezone', 'user_active'] as $f) {
- $val = $input->getOption($f);
- if ($val !== null) {
- $body[$f] = $val;
- }
- }
+ $body = $this->collectOptions($input, ['user_fname', 'user_lname', 'user_email', 'user_timezone', 'user_active']);
// Handle password separately — prompt securely if --user_pass given without value
$passVal = $input->getOption('user_pass');
if ($passVal === null && $input->hasParameterOption('--user_pass')) {
- $helper = $this->getHelper('question');
- $question = new Question('New password (hidden): ');
- $question->setHidden(true);
- $question->setHiddenFallback(false);
- $passVal = $helper->ask($input, $output, $question);
+ $passVal = $this->promptHiddenSecret($input, $output, 'New password (hidden): ');
}
- if ($passVal !== null && $passVal !== false && $passVal !== '') {
+ if (is_string($passVal) && $passVal !== '') {
$body['user_pass'] = $passVal;
}
if (empty($body)) {
diff --git a/cli/Config.php b/cli/Config.php
index e674c76a..6df7037a 100644
--- a/cli/Config.php
+++ b/cli/Config.php
@@ -20,24 +20,60 @@ public function __construct()
private function load(): void
{
- if (file_exists($this->configFile)) {
- $json = file_get_contents($this->configFile);
- $this->data = json_decode($json, true) ?: [];
+ if (!file_exists($this->configFile)) {
+ return;
}
+
+ $json = file_get_contents($this->configFile);
+ if ($json === false) {
+ throw new \RuntimeException("Unable to read config file: {$this->configFile}");
+ }
+ if (trim($json) === '') {
+ return;
+ }
+
+ $decoded = json_decode($json, true);
+ if (!is_array($decoded)) {
+ // A corrupt config must not be silently treated as empty — the
+ // next save would overwrite it and destroy the remaining keys
+ // (api_key, url) without the user ever knowing.
+ throw new \RuntimeException(
+ "Config file {$this->configFile} contains invalid JSON. "
+ . 'Fix or remove it, then re-run configuration.'
+ );
+ }
+ $this->data = $decoded;
}
public function save(): void
{
- if (!is_dir($this->configDir)) {
- mkdir($this->configDir, 0700, true);
+ if (!is_dir($this->configDir) && !mkdir($this->configDir, 0700, true) && !is_dir($this->configDir)) {
+ throw new \RuntimeException("Unable to create config directory: {$this->configDir}");
}
+
+ $json = json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($json === false) {
+ throw new \RuntimeException('Unable to encode config: ' . json_last_error_msg());
+ }
+
+ // Write to a temp file and rename so a killed process can never leave
+ // a truncated config.json behind.
+ $tmp = $this->configFile . '.tmp';
$oldUmask = umask(0077);
- file_put_contents(
- $this->configFile,
- json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
- );
- chmod($this->configFile, 0600);
- umask($oldUmask);
+ try {
+ if (file_put_contents($tmp, $json . "\n") === false) {
+ throw new \RuntimeException("Unable to write config file: {$this->configFile}");
+ }
+ chmod($tmp, 0600);
+ if (!rename($tmp, $this->configFile)) {
+ throw new \RuntimeException("Unable to finalize config file: {$this->configFile}");
+ }
+ } finally {
+ if (file_exists($tmp)) {
+ @unlink($tmp);
+ }
+ umask($oldUmask);
+ }
}
public function get(string $key, mixed $default = null): mixed
diff --git a/cli/Formatter.php b/cli/Formatter.php
index 450209c7..9727e749 100644
--- a/cli/Formatter.php
+++ b/cli/Formatter.php
@@ -12,7 +12,11 @@ class Formatter
public static function output(OutputInterface $output, array $data, bool $json = false): void
{
if ($json) {
- $output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($encoded === false) {
+ throw new \RuntimeException('Failed to encode output as JSON: ' . json_last_error_msg());
+ }
+ $output->writeln($encoded);
return;
}
diff --git a/documentation/features/advanced-attribution-engine.md b/documentation/features/advanced-attribution-engine.md
index 0eb944b8..63a6b8d0 100644
--- a/documentation/features/advanced-attribution-engine.md
+++ b/documentation/features/advanced-attribution-engine.md
@@ -65,7 +65,7 @@ This guide tracks the remaining work to deliver the Advanced Attribution Engine
- **Accessing the dashboard:** Navigate to **Account ▸ Attribution** to open `202-account/attribution.php`. The page sets `data-api-base` to `/api/v2/attribution`, and `202-js/attribution.js` drives the UI against that **v2** surface: KPI cards and chart regions call `/api/v2/attribution/metrics`, and the model selector calls `/api/v2/attribution/models`. (The separately documented [v3 Attribution API](../api/13-attribution.md) exposes `/attribution/models` plus snapshot/export sub-resources for programmatic and CLI access; the dashboard does not call v3 directly.)
- **Using the sandbox:** Select comparison models in the sandbox panel. The UI calls `/api/v2/attribution/sandbox`, surfacing placeholder insights until the computation engine backfills live metrics; promote-to-default actions send `PATCH /api/v2/attribution/models/{id}`.
- **Scheduling exports:** Use the export drawer on the dashboard to request CSV/XLS snapshots. The UI calls `POST /api/v2/attribution/models/{id}/exports`, enqueueing jobs in `202_attribution_exports` and generating download tokens served through `202-account/attribution-export.php`.
-- **Processing pipeline:** The cron worker `202-cronjobs/attribution-export.php` claims pending jobs, streams snapshot data through `SnapshotExporter`, and issues optional webhooks using `WebhookDispatcher`. Export files are processed using chunked encoding to minimize memory usage, with a 10MB size limit for webhook dispatch. Logs appear in cron output, and job status updates render in the dashboard export history list.
+- **Processing pipeline:** The cron worker `202-cronjobs/attribution-export.php` claims pending jobs, writes the snapshot file, and posts the optional webhook itself: the URL is re-checked in full at delivery by `OutboundUrlGuard::assertAllowed()` (write boundaries only check shape, since DNS can change in between) and the connection is pinned to a validated address via `OutboundUrlGuard::curlOptions()`. Logs appear in cron output, and job status updates render in the dashboard export history list.
## How to Use This Checklist
1. Review each section before beginning implementation work for the sprint.
diff --git a/go-cli/cmd/cmd_test.go b/go-cli/cmd/cmd_test.go
index 2eb65ced..371f72ff 100644
--- a/go-cli/cmd/cmd_test.go
+++ b/go-cli/cmd/cmd_test.go
@@ -2569,21 +2569,32 @@ func TestTrackerCreateWithURL(t *testing.T) {
}
func TestTrackerBulkURLs(t *testing.T) {
+ // bulk-urls fans the per-tracker fetches over a worker pool, so this
+ // handler runs on several goroutines at once and its bookkeeping needs a
+ // lock. Without it `go test -race` fails here by scheduling luck rather
+ // than by anything the CLI did.
+ var mu sync.Mutex
var listQuery url.Values
urlCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/api/v3/trackers":
+ mu.Lock()
listQuery = r.URL.Query()
+ mu.Unlock()
w.WriteHeader(200)
w.Write([]byte(`{"data":[{"tracker_id":1,"aff_campaign_id":10},{"tracker_id":2,"aff_campaign_id":10}]}`))
case r.Method == "GET" && r.URL.Path == "/api/v3/trackers/1/url":
+ mu.Lock()
urlCalls++
+ mu.Unlock()
w.WriteHeader(200)
w.Write([]byte(`{"data":{"tracker_id":1,"direct_url":"https://trk.example/1"}}`))
case r.Method == "GET" && r.URL.Path == "/api/v3/trackers/2/url":
+ mu.Lock()
urlCalls++
+ mu.Unlock()
w.WriteHeader(200)
w.Write([]byte(`{"data":{"tracker_id":2,"direct_url":"https://trk.example/2"}}`))
default:
@@ -2602,11 +2613,15 @@ func TestTrackerBulkURLs(t *testing.T) {
t.Fatalf("tracker bulk-urls error: %v", err)
}
- if got := listQuery.Get("filter[aff_campaign_id]"); got != "10" {
- t.Errorf("filter[aff_campaign_id] = %q, want %q", got, "10")
+ mu.Lock()
+ gotFilter := listQuery.Get("filter[aff_campaign_id]")
+ gotCalls := urlCalls
+ mu.Unlock()
+ if gotFilter != "10" {
+ t.Errorf("filter[aff_campaign_id] = %q, want %q", gotFilter, "10")
}
- if urlCalls != 2 {
- t.Errorf("urlCalls = %d, want 2", urlCalls)
+ if gotCalls != 2 {
+ t.Errorf("urlCalls = %d, want 2", gotCalls)
}
if !strings.Contains(stdout, "https://trk.example/1") || !strings.Contains(stdout, "https://trk.example/2") {
t.Errorf("output should contain both tracker URLs, got:\n%s", stdout)
diff --git a/go-cli/cmd/config.go b/go-cli/cmd/config.go
index 350eb593..65e7d3bf 100644
--- a/go-cli/cmd/config.go
+++ b/go-cli/cmd/config.go
@@ -1,9 +1,13 @@
package cmd
import (
+ "bufio"
"encoding/json"
+ "errors"
"fmt"
+ "io"
"net/url"
+ "os"
"strings"
"p202/internal/api"
@@ -11,6 +15,7 @@ import (
"p202/internal/output"
"github.com/spf13/cobra"
+ "golang.org/x/term"
)
var configCmd = &cobra.Command{
@@ -45,15 +50,41 @@ var configSetURLCmd = &cobra.Command{
}
var configSetKeyCmd = &cobra.Command{
- Use: "set-key ",
- Short: "Set the API key",
- Args: cobra.ExactArgs(1),
+ Use: "set-key [api-key]",
+ Short: "Set the API key (omit the argument to be prompted without echoing)",
+ Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
- apiKey := strings.TrimSpace(args[0])
+ var apiKey string
+ if len(args) == 1 {
+ apiKey = strings.TrimSpace(args[0])
+ } else {
+ // An API key is a bearer credential — at least as sensitive as the
+ // password that `user create` already reads with term.ReadPassword.
+ // Prompting keeps it out of shell history and ps output.
+ //
+ // term.ReadPassword needs a real terminal, so when stdin is piped
+ // (echo "$KEY" | p202 config set-key, or CI) fall back to a plain
+ // read instead of failing with "inappropriate ioctl for device".
+ if term.IsTerminal(int(os.Stdin.Fd())) {
+ fmt.Fprint(os.Stderr, "API key (hidden): ")
+ keyBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return fmt.Errorf("reading API key: %w", err)
+ }
+ apiKey = strings.TrimSpace(string(keyBytes))
+ } else {
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return fmt.Errorf("reading API key: %w", err)
+ }
+ apiKey = strings.TrimSpace(line)
+ }
+ }
if err := validateAPIKey(apiKey); err != nil {
return err
}
@@ -122,7 +153,7 @@ var configTestCmd = &cobra.Command{
}
data, err := c.Get("system/health", nil)
if err != nil {
- return withHint(fmt.Errorf("connection failed: %w", err), "Check `p202 config get` (URL and key), that the instance is reachable, and that the key is valid in the Prosper202 UI under API keys.")
+ return withHint(fmt.Errorf("connection failed: %w", err), "Check `p202 config show` (URL and key), that the instance is reachable, and that the key is valid in the Prosper202 UI under API keys.")
}
if !jsonOutput {
fmt.Println("Connection successful!")
diff --git a/go-cli/cmd/conversion.go b/go-cli/cmd/conversion.go
index dfbbd6de..3d700e3a 100644
--- a/go-cli/cmd/conversion.go
+++ b/go-cli/cmd/conversion.go
@@ -3,12 +3,9 @@ package cmd
import (
"encoding/json"
"fmt"
- "os"
"strconv"
- "strings"
"p202/internal/api"
- "p202/internal/output"
"github.com/spf13/cobra"
)
@@ -102,11 +99,11 @@ var conversionCreateCmd = &cobra.Command{
clickIDStr, _ = cmd.Flags().GetString("click_id_public")
}
if clickIDStr == "" {
- return validationError("required flag --click_id (or --click_id_public) is missing")
+ return fmt.Errorf("required flag --click_id (or --click_id_public) is missing")
}
clickID, err := strconv.Atoi(clickIDStr)
if err != nil {
- return validationError("--click_id must be an integer: %s", clickIDStr).WithHint("Use the internal click id from `p202 click list`, or pass the public id via --click_id_public.")
+ return fmt.Errorf("--click_id must be an integer: %s", clickIDStr)
}
body := map[string]interface{}{
"click_id": clickID,
@@ -132,76 +129,13 @@ var conversionCreateCmd = &cobra.Command{
var conversionDeleteCmd = &cobra.Command{
Use: "delete ",
Short: "Delete a conversion",
- Args: func(cmd *cobra.Command, args []string) error {
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- return cobra.MaximumNArgs(0)(cmd, args)
- }
- return cobra.ExactArgs(1)(cmd, args)
- },
+ Args: deleteArgsValidator,
RunE: func(cmd *cobra.Command, args []string) error {
- c, err := api.NewFromConfig()
- if err != nil {
- return err
- }
- dryRun, _ := cmd.Flags().GetBool("dry-run")
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- idList, parseErr := parseIDList(idsFlag)
- if parseErr != nil {
- return parseErr
- }
- if len(idList) == 0 {
- return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).")
- }
-
- if dryRun {
- return renderDeletePreviews(c, "conversions", idList)
- }
- if api.StagedMode() {
- return stageDeletes(c, "conversions", idList)
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete %d conversions?", len(idList)) {
- fmt.Println("Cancelled.")
- return nil
- }
-
- deleted := 0
- failed := 0
- for _, id := range idList {
- if err := c.Delete("conversions/" + id); err != nil {
- failed++
- fmt.Fprintf(os.Stderr, "Failed to delete conversion %s: %v\n", id, err)
- continue
- }
- deleted++
- }
- output.Success("Deleted %d of %d conversions.", deleted, len(idList))
- if failed > 0 {
- return partialFailureError("failed to delete %d conversions", failed)
- }
- return nil
- }
-
- if dryRun {
- return renderDeletePreviews(c, "conversions", []string{args[0]})
- }
- if api.StagedMode() {
- return stageDeletes(c, "conversions", []string{args[0]})
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete conversion %s?", args[0]) {
- fmt.Println("Cancelled.")
- return nil
- }
- if err := c.Delete("conversions/" + args[0]); err != nil {
- return err
- }
- output.Success("Conversion %s deleted.", args[0])
- return nil
+ return runBulkOrSingleDelete(cmd, args, deleteSpec{
+ endpoint: "conversions",
+ noun: "conversion",
+ plural: "conversions",
+ })
},
}
diff --git a/go-cli/cmd/crosstab.go b/go-cli/cmd/crosstab.go
index a5d50c88..170f93a1 100644
--- a/go-cli/cmd/crosstab.go
+++ b/go-cli/cmd/crosstab.go
@@ -89,7 +89,10 @@ var reportCrosstabCmd = &cobra.Command{
if len(opts.Fields) == 0 {
opts.Fields = append([]string{rowDim}, cols...)
}
- out, _ := json.Marshal(map[string]interface{}{"data": matrix})
+ out, err := json.Marshal(map[string]interface{}{"data": matrix})
+ if err != nil {
+ return fmt.Errorf("encoding crosstab matrix: %w", err)
+ }
output.RenderWith(out, opts)
return nil
},
diff --git a/go-cli/cmd/crud.go b/go-cli/cmd/crud.go
index 095ff0ad..3098cdd5 100644
--- a/go-cli/cmd/crud.go
+++ b/go-cli/cmd/crud.go
@@ -95,12 +95,18 @@ func isNotFoundErr(err error) bool {
return false
}
-// deleteArgsValidator allows zero positional args when --ids is set, else one.
-func deleteArgsValidator(cmd *cobra.Command, args []string) error {
- if ids, _ := cmd.Flags().GetString("ids"); strings.TrimSpace(ids) != "" {
- return cobra.MaximumNArgs(0)(cmd, args)
+// deleteArgsValidatorN returns a cobra Args validator for delete commands whose
+// deletable id is preceded by `base` fixed positional args (0 for flat
+// resources, 1 for nested ones like rotator rules): with --ids set the id list
+// replaces the positional id, so exactly `base` args are allowed; otherwise
+// base+1.
+func deleteArgsValidatorN(base int) cobra.PositionalArgs {
+ return func(cmd *cobra.Command, args []string) error {
+ if ids, _ := cmd.Flags().GetString("ids"); strings.TrimSpace(ids) != "" {
+ return cobra.ExactArgs(base)(cmd, args)
+ }
+ return cobra.ExactArgs(base+1)(cmd, args)
}
- return cobra.ExactArgs(1)(cmd, args)
}
// deleteDryRunFlagDesc is the shared help text for --dry-run on every delete
@@ -232,7 +238,50 @@ func withDryRunHint(err error) error {
// bulkOrSingleDelete deletes one id (positional) or many (--ids), honoring
// --force, against endpoint/. Shared so every delete has the same bulk
// semantics. noun is used in confirmation and summary messages.
+
+// deleteArgsValidator allows zero positional args when --ids is set, else one.
+var deleteArgsValidator = deleteArgsValidatorN(0)
+
+// deleteSpec describes what varies between the CLI's delete commands: the URL
+// for one id, and the wording. Everything else — id validation, the --ids bulk
+// path, confirmation, partial-failure accounting, which stream each message
+// goes to — is shared in runBulkOrSingleDelete. These used to be five
+// hand-rolled copies, and the copies are exactly where the mechanics drifted
+// (prompts on stdout, unvalidated ids); the wording is the only part that was
+// ever meant to differ.
+type deleteSpec struct {
+ endpoint string // collection path; one record is endpoint + "/" + id
+ noun string // singular, e.g. "rotator"
+ plural string // bulk prompts and summaries, e.g. "rotators"
+ cascadeOne string // single-confirm suffix, e.g. " and all its rules"
+ cascadeMany string // bulk-confirm suffix, e.g. " and all their rules"
+ context string // parent-resource suffix, e.g. " from rotator 7"
+ // idsHintText overrides the --ids recovery hint for commands whose ids are
+ // not discoverable via a plain ` list` (rotator rules, for example).
+ idsHintText string
+}
+
+// idsHint returns the recovery hint shown when --ids resolves to nothing.
+func (s deleteSpec) idsHint() string {
+ if s.idsHintText != "" {
+ return s.idsHintText
+ }
+ return "Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`)."
+}
+
+// bulkOrSingleDelete is the flat-resource convenience wrapper around
+// runBulkOrSingleDelete for callers with no special wording.
func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error {
+ return runBulkOrSingleDelete(cmd, cmd.Flags().Args(), deleteSpec{
+ endpoint: endpoint,
+ noun: noun,
+ plural: noun + "s",
+ })
+}
+
+// runBulkOrSingleDelete deletes one id (from args) or many (--ids), honoring
+// --force. Prompts and cancellations go to stderr so piped stdout stays data.
+func runBulkOrSingleDelete(cmd *cobra.Command, args []string, spec deleteSpec) error {
c, err := api.NewFromConfig()
if err != nil {
return err
@@ -240,7 +289,6 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error {
force, _ := cmd.Flags().GetBool("force")
dryRun, _ := cmd.Flags().GetBool("dry-run")
idsFlag, _ := cmd.Flags().GetString("ids")
- args := cmd.Flags().Args()
if strings.TrimSpace(idsFlag) != "" {
ids, perr := parseIDList(idsFlag)
@@ -248,30 +296,30 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error {
return perr
}
if len(ids) == 0 {
- return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).")
+ return validationError("--ids requires at least one ID").WithHint(spec.idsHint())
}
if dryRun {
- return renderDeletePreviews(c, endpoint, ids)
+ return renderDeletePreviews(c, spec.endpoint, ids)
}
if api.StagedMode() {
- return stageDeletes(c, endpoint, ids)
+ return stageDeletes(c, spec.endpoint, ids)
}
- if !force && !confirmPrompt("Delete %d %ss?", len(ids), noun) {
+ if !force && !confirmPrompt("Delete %d %s%s%s?", len(ids), spec.plural, spec.cascadeMany, spec.context) {
fmt.Fprintln(os.Stderr, "Cancelled.")
return nil
}
deleted, failed := 0, 0
for _, id := range ids {
- if err := c.Delete(endpoint + "/" + id); err != nil {
+ if err := c.Delete(spec.endpoint + "/" + id); err != nil {
failed++
- fmt.Fprintf(os.Stderr, "Failed to delete %s %s: %v\n", noun, id, err)
+ fmt.Fprintf(os.Stderr, "Failed to delete %s %s%s: %v\n", spec.noun, id, spec.context, err)
continue
}
deleted++
}
- output.Success("Deleted %d of %d %ss.", deleted, len(ids), noun)
+ output.Success("Deleted %d of %d %s%s.", deleted, len(ids), spec.plural, spec.context)
if failed > 0 {
- return partialFailureError("failed to delete %d %ss", failed, noun)
+ return partialFailureError("failed to delete %d %s", failed, spec.plural)
}
return nil
}
@@ -279,20 +327,27 @@ func bulkOrSingleDelete(cmd *cobra.Command, endpoint, noun string) error {
if len(args) != 1 {
return validationError("provide a single id or use --ids").WithHint("Pass one id as the argument, or several with --ids 12,13,14.")
}
+ // Validate before previewing or staging: a preview is still a DELETE
+ // request, so a blank or non-numeric id must not reach the server on any
+ // of these paths.
+ id, err := validateID(args[0])
+ if err != nil {
+ return err
+ }
if dryRun {
- return renderDeletePreviews(c, endpoint, []string{args[0]})
+ return renderDeletePreviews(c, spec.endpoint, []string{id})
}
if api.StagedMode() {
- return stageDeletes(c, endpoint, []string{args[0]})
+ return stageDeletes(c, spec.endpoint, []string{id})
}
- if !force && !confirmPrompt("Delete %s %s?", noun, args[0]) {
+ if !force && !confirmPrompt("Delete %s %s%s%s?", spec.noun, id, spec.cascadeOne, spec.context) {
fmt.Fprintln(os.Stderr, "Cancelled.")
return nil
}
- if err := c.Delete(endpoint + "/" + args[0]); err != nil {
+ if err := c.Delete(spec.endpoint + "/" + id); err != nil {
return err
}
- output.Success("%s %s deleted.", capitalize(noun), args[0])
+ output.Success("%s %s deleted%s.", capitalize(spec.noun), id, spec.context)
return nil
}
@@ -446,6 +501,31 @@ func cloneMutableFields(source map[string]interface{}, fields []crudField) map[s
return out
}
+// requireID rejects a blank positional id. Interpolating one produced a request
+// against the collection endpoint itself (DELETE users/) rather than against a
+// record — a very different operation from the one the user asked for.
+func requireID(raw string) (string, error) {
+ id := strings.TrimSpace(raw)
+ if id == "" {
+ return "", validationError("an ID is required")
+ }
+ return id, nil
+}
+
+// validateID additionally enforces, for a single positional id, the same numeric
+// rule parseIDList applies to every id in --ids. Used by the mutating commands;
+// `get` uses requireID instead because it also accepts public ids.
+func validateID(raw string) (string, error) {
+ id, err := requireID(raw)
+ if err != nil {
+ return "", err
+ }
+ if _, err := strconv.Atoi(id); err != nil {
+ return "", validationError("invalid ID %q: must be a numeric value", id)
+ }
+ return id, nil
+}
+
func parseIDList(raw string) ([]string, error) {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
@@ -656,8 +736,12 @@ func registerCRUD(entity crudEntity) *cobra.Command {
if err != nil {
return err
}
+ id, err := requireID(args[0])
+ if err != nil {
+ return err
+ }
forcePublic, _ := cmd.Flags().GetBool("public")
- data, err := getWithPublicFallback(c, entity, args[0], forcePublic)
+ data, err := getWithPublicFallback(c, entity, id, forcePublic)
if err != nil {
return err
}
@@ -726,7 +810,11 @@ func registerCRUD(entity crudEntity) *cobra.Command {
if len(body) == 0 {
return validationError("no fields specified; pass at least one flag to update")
}
- data, err := c.Put(entity.Endpoint+"/"+args[0], body)
+ id, err := validateID(args[0])
+ if err != nil {
+ return err
+ }
+ data, err := c.Put(entity.Endpoint+"/"+id, body)
if err != nil {
return err
}
@@ -742,78 +830,15 @@ func registerCRUD(entity crudEntity) *cobra.Command {
deleteCmd := &cobra.Command{
Use: "delete ",
Short: fmt.Sprintf("Delete a %s", entity.Name),
- Args: func(cmd *cobra.Command, args []string) error {
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- return cobra.MaximumNArgs(0)(cmd, args)
- }
- return cobra.ExactArgs(1)(cmd, args)
- },
+ Args: deleteArgsValidator,
RunE: func(cmd *cobra.Command, args []string) (retErr error) {
done := metrics.Timer("delete", entity.Endpoint)
defer func() { done(retErr == nil, errString(retErr)) }()
- c, err := api.NewFromConfig()
- if err != nil {
- return err
- }
- dryRun, _ := cmd.Flags().GetBool("dry-run")
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- idList, parseErr := parseIDList(idsFlag)
- if parseErr != nil {
- return parseErr
- }
- if len(idList) == 0 {
- return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).")
- }
-
- if dryRun {
- return renderDeletePreviews(c, entity.Endpoint, idList)
- }
- if api.StagedMode() {
- return stageDeletes(c, entity.Endpoint, idList)
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete %d %s?", len(idList), entity.Plural) {
- fmt.Println("Cancelled.")
- return nil
- }
-
- deleted := 0
- failed := 0
- for _, id := range idList {
- if err := c.Delete(entity.Endpoint + "/" + id); err != nil {
- failed++
- fmt.Fprintf(os.Stderr, "Failed to delete %s %s: %v\n", entity.Name, id, err)
- continue
- }
- deleted++
- }
- output.Success("Deleted %d of %d %s.", deleted, len(idList), entity.Plural)
- if failed > 0 {
- return partialFailureError("failed to delete %d %s", failed, entity.Plural)
- }
- return nil
- }
-
- if dryRun {
- return renderDeletePreviews(c, entity.Endpoint, []string{args[0]})
- }
- if api.StagedMode() {
- return stageDeletes(c, entity.Endpoint, []string{args[0]})
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete %s %s?", entity.Name, args[0]) {
- fmt.Println("Cancelled.")
- return nil
- }
- if err := c.Delete(entity.Endpoint + "/" + args[0]); err != nil {
- return err
- }
- output.Success("%s %s deleted.", capitalize(entity.Name), args[0])
- return nil
+ return runBulkOrSingleDelete(cmd, args, deleteSpec{
+ endpoint: entity.Endpoint,
+ noun: entity.Name,
+ plural: entity.Plural,
+ })
},
}
registerDeleteFlags(deleteCmd, entity.Name)
@@ -1228,16 +1253,37 @@ func init() {
close(results)
}()
- ordered := make([]map[string]interface{}, len(trackers))
+ // Report per-tracker failures and keep the rows that succeeded,
+ // matching how the bulk deletes account for partial failure.
+ // Returning on the first error discarded every row already
+ // fetched, so one transient 500 threw away the whole listing.
+ indexed := make([]map[string]interface{}, len(trackers))
+ failed := 0
for result := range results {
if result.err != nil {
- return result.err
+ failed++
+ fmt.Fprintf(os.Stderr, "Failed to fetch URL for tracker at row %d: %v\n", result.index+1, result.err)
+ continue
+ }
+ indexed[result.index] = result.row
+ }
+
+ // Drop the gaps left by failed rows rather than emitting nulls.
+ ordered := make([]map[string]interface{}, 0, len(trackers)-failed)
+ for _, row := range indexed {
+ if row != nil {
+ ordered = append(ordered, row)
}
- ordered[result.index] = result.row
}
- encoded, _ := json.Marshal(map[string]interface{}{"data": ordered})
+ encoded, err := json.Marshal(map[string]interface{}{"data": ordered})
+ if err != nil {
+ return fmt.Errorf("encoding tracker URLs: %w", err)
+ }
render(encoded)
+ if failed > 0 {
+ return partialFailureError("failed to fetch %d of %d tracker URLs", failed, len(trackers))
+ }
return nil
},
}
diff --git a/go-cli/cmd/destructive_args_test.go b/go-cli/cmd/destructive_args_test.go
new file mode 100644
index 00000000..cbc5023f
--- /dev/null
+++ b/go-cli/cmd/destructive_args_test.go
@@ -0,0 +1,198 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// recordingServer captures every request the CLI actually sends, so a test can
+// assert that a rejected command sent nothing at all.
+type recordingServer struct {
+ *httptest.Server
+ mu sync.Mutex
+ requests []string
+}
+
+func newRecordingServer(t *testing.T) *recordingServer {
+ t.Helper()
+ rs := &recordingServer{}
+ rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rs.mu.Lock()
+ rs.requests = append(rs.requests, r.Method+" "+r.URL.Path)
+ rs.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{}}`))
+ }))
+ t.Cleanup(rs.Close)
+ return rs
+}
+
+func (rs *recordingServer) seen() []string {
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+ return append([]string(nil), rs.requests...)
+}
+
+// A blank positional id used to be interpolated straight into the request path,
+// producing a request against the collection endpoint (DELETE campaigns/)
+// instead of against a record. Every id-taking mutation must reject it before
+// any request leaves the process.
+func TestBlankOrNonNumericIDsAreRejectedBeforeAnyRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ }{
+ {"campaign delete blank", []string{"campaign", "delete", "", "--force"}},
+ {"campaign delete whitespace", []string{"campaign", "delete", " ", "--force"}},
+ {"campaign delete non-numeric", []string{"campaign", "delete", "../users", "--force"}},
+ {"campaign update blank", []string{"campaign", "update", "", "--aff_campaign_name", "x"}},
+ {"rotator delete blank", []string{"rotator", "delete", "", "--force"}},
+ {"conversion delete blank", []string{"conversion", "delete", "", "--force"}},
+ {"user delete blank", []string{"user", "delete", "", "--force"}},
+ {"rotator rule-delete blank rotator", []string{"rotator", "rule-delete", "", "5", "--force"}},
+ {"rotator rule-delete blank rule", []string{"rotator", "rule-delete", "5", "", "--force"}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newRecordingServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ _, _, err := executeCommand(tc.args...)
+ if err == nil {
+ t.Fatalf("expected a validation error, got nil (requests: %v)", srv.seen())
+ }
+ // Assert the rejection is specifically about the id. Without this a
+ // mistyped command name would satisfy the test for the wrong reason.
+ if msg := err.Error(); !strings.Contains(msg, "ID") {
+ t.Fatalf("error should name the invalid ID, got %q", msg)
+ }
+ for _, req := range srv.seen() {
+ if strings.HasPrefix(req, "DELETE") || strings.HasPrefix(req, "PUT") {
+ t.Fatalf("a mutating request was sent despite the invalid id: %s", req)
+ }
+ }
+ })
+ }
+}
+
+// Cancelling a delete must not print to stdout: these commands are scripted, and
+// "Cancelled." landing in a piped stdout corrupts the caller's data stream. With
+// no terminal attached the confirmation read fails, which is the cancel path.
+func TestCancelledDeletesKeepStdoutClean(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ }{
+ {"campaign delete", []string{"campaign", "delete", "7"}},
+ {"rotator delete", []string{"rotator", "delete", "7"}},
+ {"conversion delete", []string{"conversion", "delete", "7"}},
+ {"user delete", []string{"user", "delete", "7"}},
+ {"rotator rule-delete", []string{"rotator", "rule-delete", "7", "9"}},
+ {"campaign bulk delete", []string{"campaign", "delete", "--ids", "7,8"}},
+ {"rotator bulk delete", []string{"rotator", "delete", "--ids", "7,8"}},
+ {"conversion bulk delete", []string{"conversion", "delete", "--ids", "7,8"}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newRecordingServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ stdout, stderr, err := executeCommand(tc.args...)
+ if err != nil {
+ t.Fatalf("cancelling should not be an error: %v", err)
+ }
+ if strings.Contains(stdout, "Cancelled") {
+ t.Fatalf("cancellation notice went to stdout: %q", stdout)
+ }
+ if !strings.Contains(stderr, "Cancelled") {
+ t.Fatalf("cancellation notice missing from stderr: %q", stderr)
+ }
+ for _, req := range srv.seen() {
+ if strings.HasPrefix(req, "DELETE") {
+ t.Fatalf("a cancelled delete still sent %s", req)
+ }
+ }
+ })
+ }
+}
+
+// The confirmation question itself must also stay off stdout.
+func TestConfirmationPromptDoesNotWriteToStdout(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newRecordingServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ stdout, stderr, err := executeCommand("rotator", "rule-delete", "7", "9")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if strings.Contains(stdout, "[y/N]") {
+ t.Fatalf("prompt was written to stdout: %q", stdout)
+ }
+ if !strings.Contains(stderr, "[y/N]") {
+ t.Fatalf("prompt missing from stderr: %q", stderr)
+ }
+}
+
+// The delete commands now share one runner and differ only in a wording spec.
+// Pin the user-visible strings so a spec edit can't silently change the UX the
+// old hand-rolled copies had.
+func TestDeleteWordingIsPreserved(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ wantStderr string
+ }{
+ {"rotator single confirm keeps cascade warning",
+ []string{"rotator", "delete", "7"},
+ "Delete rotator 7 and all its rules?"},
+ {"rotator bulk confirm keeps cascade warning",
+ []string{"rotator", "delete", "--ids", "7,8"},
+ "Delete 2 rotators and all their rules?"},
+ {"rule single confirm names the parent rotator",
+ []string{"rotator", "rule-delete", "7", "9"},
+ "Delete rule 9 from rotator 7?"},
+ {"rule bulk confirm names the parent rotator",
+ []string{"rotator", "rule-delete", "7", "--ids", "9,11"},
+ "Delete 2 rules from rotator 7?"},
+ {"conversion single success",
+ []string{"conversion", "delete", "7", "--force"},
+ "Conversion 7 deleted."},
+ {"rule single success names the parent rotator",
+ []string{"rotator", "rule-delete", "7", "9", "--force"},
+ "Rule 9 deleted from rotator 7."},
+ {"rule bulk summary names the parent rotator",
+ []string{"rotator", "rule-delete", "7", "--ids", "9,11", "--force"},
+ "Deleted 2 of 2 rules from rotator 7."},
+ {"campaign bulk summary uses the entity plural",
+ []string{"campaign", "delete", "--ids", "7,8", "--force"},
+ "Deleted 2 of 2 campaigns"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newRecordingServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ _, stderr, err := executeCommand(tc.args...)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stderr, tc.wantStderr) {
+ t.Fatalf("stderr = %q, want it to contain %q", stderr, tc.wantStderr)
+ }
+ })
+ }
+}
diff --git a/go-cli/cmd/diff.go b/go-cli/cmd/diff.go
index 08e08537..540aa4bc 100644
--- a/go-cli/cmd/diff.go
+++ b/go-cli/cmd/diff.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "os"
"sort"
"strconv"
"strings"
@@ -561,9 +562,22 @@ func scalarString(v interface{}) string {
}
}
+// comparableEqual reports whether two records are identical. Both operands are
+// marshalled and compared byte-wise, which is stable because encoding/json sorts
+// map keys.
+//
+// An encoding failure must report "not equal", never "equal". Both failures
+// yielded nil, and bytes.Equal(nil, nil) is true — so a record pair that could
+// not be encoded was declared unchanged, and sync would silently skip a real
+// difference. Erring toward "changed" makes the failure visible as a redundant
+// update instead of missing data.
func comparableEqual(a, b map[string]interface{}) bool {
- aBytes, _ := json.Marshal(a)
- bBytes, _ := json.Marshal(b)
+ aBytes, aErr := json.Marshal(a)
+ bBytes, bErr := json.Marshal(b)
+ if aErr != nil || bErr != nil {
+ fmt.Fprintf(os.Stderr, "Warning: could not encode records for comparison (%v / %v); treating them as changed.\n", aErr, bErr)
+ return false
+ }
return bytes.Equal(aBytes, bBytes)
}
@@ -590,9 +604,11 @@ func changedFields(a, b map[string]interface{}) []string {
out = append(out, key)
continue
}
- aBytes, _ := json.Marshal(aRaw)
- bBytes, _ := json.Marshal(bRaw)
- if !bytes.Equal(aBytes, bBytes) {
+ aBytes, aErr := json.Marshal(aRaw)
+ bBytes, bErr := json.Marshal(bRaw)
+ // As in comparableEqual: an encoding failure must not be reported as an
+ // unchanged field, which would hide the difference from the sync.
+ if aErr != nil || bErr != nil || !bytes.Equal(aBytes, bBytes) {
out = append(out, key)
}
}
diff --git a/go-cli/cmd/forecast.go b/go-cli/cmd/forecast.go
index 89074924..0f9a46fc 100644
--- a/go-cli/cmd/forecast.go
+++ b/go-cli/cmd/forecast.go
@@ -325,7 +325,7 @@ func runForecast(cmd *cobra.Command, args []string) error {
series := parsed[metric]
if len(series) == 0 {
- available := parsedMetricNames(parsed)
+ available := responseMetricNames(data)
if len(available) == 0 {
return validationError("no valid data points found for metric %q", metric).
WithHint("No bucket in the response carried a numeric %q value. Check `p202 report timeseries --period %s` with the same filters to see which metrics the API returns for this window.", metric, history)
@@ -659,15 +659,41 @@ func parseTimeseriesMulti(data []byte, metrics []string) (map[string]forecast.Se
return out, rejected, nil
}
-// parsedMetricNames lists the metrics a parsed response carried values for,
-// sorted, for error hints.
-func parsedMetricNames(parsed map[string]forecast.Series) []string {
- names := make([]string, 0, len(parsed))
- for m, s := range parsed {
- if len(s) > 0 {
- names = append(names, m)
+// responseMetricNames lists every forecastable metric the RAW response carried a
+// numeric value for, sorted.
+//
+// The recovery hint must not use parsedMetricNames: parseTimeseriesMulti only
+// populates the metrics it was asked for (the coherent inputs plus the requested
+// one), so naming those listed a subset of what would actually work and hid the
+// rest of the valid choices from anyone following the hint.
+func responseMetricNames(data []byte) []string {
+ var parsed map[string]interface{}
+ if json.Unmarshal(data, &parsed) != nil {
+ return nil
+ }
+ rawItems, ok := parsed["data"].([]interface{})
+ if !ok {
+ return nil
+ }
+ seen := map[string]bool{}
+ for _, raw := range rawItems {
+ obj, ok := raw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ for m := range forecastAllowedMetrics {
+ if seen[m] {
+ continue
+ }
+ if _, ok := extractMetricValue(obj, m); ok {
+ seen[m] = true
+ }
}
}
+ names := make([]string, 0, len(seen))
+ for m := range seen {
+ names = append(names, m)
+ }
sort.Strings(names)
return names
}
diff --git a/go-cli/cmd/gate_accuracy_test.go b/go-cli/cmd/gate_accuracy_test.go
new file mode 100644
index 00000000..620c71b2
--- /dev/null
+++ b/go-cli/cmd/gate_accuracy_test.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// rotator check gates deploys, so a rotator whose detail fetch fails must be
+// reported and counted as a failure — not silently skipped with exit code 0.
+func TestRotatorCheckCountsUnfetchableRotatorsAsFailures(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/rotators/1"):
+ _, _ = w.Write([]byte(`{"data":{"id":1,"name":"healthy","default_url":"https://example.com/lp","rules":[]}}`))
+ case strings.HasSuffix(r.URL.Path, "/rotators/2"):
+ w.WriteHeader(500)
+ _, _ = w.Write([]byte(`{"message":"boom"}`))
+ case strings.HasSuffix(r.URL.Path, "/rotators"):
+ _, _ = w.Write([]byte(`{"data":[{"id":1,"name":"healthy"},{"id":2,"name":"broken"}]}`))
+ default:
+ _, _ = w.Write([]byte(`{"data":{}}`))
+ }
+ }))
+ defer srv.Close()
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ stdout, _, err := executeCommand("rotator", "check", "--json")
+ if err == nil {
+ t.Fatal("expected a failure exit when a rotator could not be fetched")
+ }
+ if !strings.Contains(err.Error(), "1 rotator(s) have configuration issues") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout, "could not fetch rotator") {
+ t.Fatalf("the unfetchable rotator must appear in the report, got %q", stdout)
+ }
+ if !strings.Contains(stdout, "broken") {
+ t.Fatalf("the failing rotator should be named, got %q", stdout)
+ }
+}
diff --git a/go-cli/cmd/hint_command_validity_test.go b/go-cli/cmd/hint_command_validity_test.go
new file mode 100644
index 00000000..8c18a5db
--- /dev/null
+++ b/go-cli/cmd/hint_command_validity_test.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// backtickedP202Command matches a `p202 ...` invocation inside a backtick-quoted
+// span in source text, which is how every recovery hint names a command.
+var backtickedP202Command = regexp.MustCompile("`(p202 [^`]+)`")
+
+// A hint that names a command which does not exist is worse than no hint: the
+// shipped example was `p202 config get`, which cobra answers by printing the
+// `config` help and exiting 0, so a scripted agent following the recovery step
+// gets no configuration and reads the diagnostic as having succeeded.
+//
+// This walks the real command tree rather than a list, so a renamed or removed
+// subcommand fails here instead of rotting in a hint string. Structural, per
+// CLAUDE.md's "closing the loop" rule: the fix for one bad hint should catch
+// the next one too.
+func TestEveryCommandNamedInAHintExists(t *testing.T) {
+ roots := []string{".", "../internal/api"}
+ checked := 0
+
+ for _, root := range roots {
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
+ return err
+ }
+ if strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ src, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return readErr
+ }
+ for _, m := range backtickedP202Command.FindAllStringSubmatch(string(src), -1) {
+ invocation := m[1]
+ words := strings.Fields(invocation)[1:] // drop "p202"
+ // Keep only the leading subcommand path; stop at the first flag
+ // or placeholder, which are arguments rather than command names.
+ var pathWords []string
+ for _, w := range words {
+ if strings.HasPrefix(w, "-") || strings.HasPrefix(w, "<") || strings.HasPrefix(w, "[") {
+ break
+ }
+ pathWords = append(pathWords, w)
+ }
+ if len(pathWords) == 0 {
+ continue
+ }
+ checked++
+ cmd, _, findErr := rootCmd.Find(pathWords)
+ if findErr != nil || cmd == nil {
+ t.Errorf("%s: hint names `%s`, but %q is not a command", path, invocation, strings.Join(pathWords, " "))
+ continue
+ }
+ // Find falls back to the nearest parent, so a shorter resolution
+ // means the trailing words were not subcommands. They are
+ // acceptable only as arguments, which requires the resolved
+ // command to actually run: a pure command group (config, user,
+ // rotator) takes no arguments, so `p202 config get` resolves to
+ // `p202 config`, prints help and exits 0 -- the failure this
+ // test exists to catch.
+ if got := strings.Fields(cmd.CommandPath())[1:]; len(got) != len(pathWords) && !cmd.Runnable() {
+ t.Errorf("%s: hint names `%s`, but %q is a command group, so %q is not a subcommand of it",
+ path, invocation, cmd.CommandPath(), pathWords[len(got)])
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walking %s: %v", root, err)
+ }
+ }
+
+ if checked == 0 {
+ t.Fatal("found no `p202 ...` hints to check - the matcher is wrong, not the tree")
+ }
+ t.Logf("checked %d commands named in hints", checked)
+}
diff --git a/go-cli/cmd/merge_delete_surface_test.go b/go-cli/cmd/merge_delete_surface_test.go
new file mode 100644
index 00000000..554c4a08
--- /dev/null
+++ b/go-cli/cmd/merge_delete_surface_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "p202/internal/api"
+)
+
+// featureServer advertises the capabilities the safety flags require and
+// records every request path+query it receives.
+type featureServer struct {
+ *httptest.Server
+ mu sync.Mutex
+ reqs []string
+}
+
+func newFeatureServer(t *testing.T) *featureServer {
+ t.Helper()
+ fs := &featureServer{}
+ fs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if strings.HasSuffix(r.URL.Path, "/capabilities") {
+ _, _ = w.Write([]byte(`{"data":{"features":{"delete_dry_run":true,"staged_writes":true}}}`))
+ return
+ }
+ fs.mu.Lock()
+ q := r.URL.RawQuery
+ entry := r.Method + " " + r.URL.Path
+ if q != "" {
+ entry += "?" + q
+ }
+ fs.reqs = append(fs.reqs, entry)
+ fs.mu.Unlock()
+ _, _ = w.Write([]byte(`{"data":{"change_id":"chg_aabbccddeeff001122334455","would_delete":1}}`))
+ }))
+ t.Cleanup(fs.Close)
+ return fs
+}
+
+func (fs *featureServer) seen() []string {
+ fs.mu.Lock()
+ defer fs.mu.Unlock()
+ return append([]string(nil), fs.reqs...)
+}
+
+// The five hand-rolled deletes were collapsed onto one runner while master was
+// independently adding --dry-run and --staged to each copy. Both had to survive
+// the merge: these are the consolidated call sites master's own tests do not
+// reach, including the nested one whose endpoint is built from a parent id.
+func TestConsolidatedDeletesCarryDryRunAndStaged(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ wantPath string
+ }{
+ {"rotator dry-run", []string{"rotator", "delete", "7", "--dry-run"}, "DELETE /api/v3/rotators/7?dry_run=1"},
+ {"conversion dry-run", []string{"conversion", "delete", "7", "--dry-run"}, "DELETE /api/v3/conversions/7?dry_run=1"},
+ {"rotator rule dry-run", []string{"rotator", "rule-delete", "3", "9", "--dry-run"}, "DELETE /api/v3/rotators/3/rules/9?dry_run=1"},
+ {"rotator bulk dry-run", []string{"rotator", "delete", "--ids", "7,8", "--dry-run"}, "DELETE /api/v3/rotators/8?dry_run=1"},
+ {"rotator staged", []string{"rotator", "delete", "7", "--staged"}, "DELETE /api/v3/rotators/7?staged=1"},
+ {"conversion staged", []string{"conversion", "delete", "7", "--staged"}, "DELETE /api/v3/conversions/7?staged=1"},
+ {"rotator rule staged", []string{"rotator", "rule-delete", "3", "9", "--staged"}, "DELETE /api/v3/rotators/3/rules/9?staged=1"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newFeatureServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+ t.Cleanup(func() { api.SetStagedMode(false) })
+
+ if _, _, err := executeCommand(tc.args...); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var found bool
+ for _, req := range srv.seen() {
+ if req == tc.wantPath {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected a request %q, got %v", tc.wantPath, srv.seen())
+ }
+ })
+ }
+}
+
+// A preview is still a DELETE request. Id validation therefore has to run
+// before the dry-run and staged branches, not after them, or an invalid id
+// reaches the server on exactly the paths added to make deletes safer.
+func TestInvalidIDIsRejectedBeforePreviewOrStaging(t *testing.T) {
+ for _, flag := range []string{"--dry-run", "--staged"} {
+ t.Run(flag, func(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newFeatureServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+ t.Cleanup(func() { api.SetStagedMode(false) })
+
+ _, _, err := executeCommand("rotator", "delete", "", flag)
+ if err == nil {
+ t.Fatalf("expected a validation error, got nil (requests: %v)", srv.seen())
+ }
+ if !strings.Contains(err.Error(), "ID") {
+ t.Fatalf("error should name the invalid ID, got %q", err)
+ }
+ for _, req := range srv.seen() {
+ if strings.HasPrefix(req, "DELETE") {
+ t.Fatalf("a DELETE reached the server despite the invalid id: %s", req)
+ }
+ }
+ })
+ }
+}
diff --git a/go-cli/cmd/numeric_output_test.go b/go-cli/cmd/numeric_output_test.go
new file mode 100644
index 00000000..880983e3
--- /dev/null
+++ b/go-cli/cmd/numeric_output_test.go
@@ -0,0 +1,91 @@
+package cmd
+
+import (
+ "io"
+ "math"
+ "os"
+ "strings"
+ "testing"
+)
+
+// captureBoth runs fn with os.Stdout and os.Stderr redirected, returning what
+// each received. Both pipes are drained concurrently so a writer can never block
+// on the kernel buffer.
+func captureBoth(fn func()) (string, string) {
+ oldStdout, oldStderr := os.Stdout, os.Stderr
+ rOut, wOut, _ := os.Pipe()
+ rErr, wErr, _ := os.Pipe()
+ os.Stdout, os.Stderr = wOut, wErr
+
+ outCh := make(chan []byte, 1)
+ errCh := make(chan []byte, 1)
+ go func() { b, _ := io.ReadAll(rOut); outCh <- b }()
+ go func() { b, _ := io.ReadAll(rErr); errCh <- b }()
+
+ fn()
+
+ os.Stdout, os.Stderr = oldStdout, oldStderr
+ _ = wOut.Close()
+ _ = wErr.Close()
+ stdout, stderr := <-outCh, <-errCh
+ _ = rOut.Close()
+ _ = rErr.Close()
+ return string(stdout), string(stderr)
+}
+
+func TestRoundHalfAwayFromZero(t *testing.T) {
+ cases := []struct {
+ in float64
+ places int
+ want float64
+ }{
+ {1.2345, 2, 1.23},
+ {1.235, 2, 1.24},
+ {-1.235, 2, -1.24},
+ {2.5, 0, 3},
+ {-2.5, 0, -3},
+ {0, 4, 0},
+ {0.28861386, 4, 0.2886},
+ }
+ for _, tc := range cases {
+ if got := round(tc.in, tc.places); math.Abs(got-tc.want) > 1e-9 {
+ t.Errorf("round(%v, %d) = %v, want %v", tc.in, tc.places, got, tc.want)
+ }
+ }
+}
+
+// The previous implementation cast through int64, which is undefined in Go once
+// the scaled value leaves the int64 range and turned NaN/Inf into an arbitrary
+// finite number. Non-finite values must pass through so they are never reported
+// as a plausible-looking figure.
+func TestRoundPreservesNonFiniteAndLargeValues(t *testing.T) {
+ if got := round(math.NaN(), 2); !math.IsNaN(got) {
+ t.Errorf("round(NaN) = %v, want NaN", got)
+ }
+ if got := round(math.Inf(1), 2); !math.IsInf(got, 1) {
+ t.Errorf("round(+Inf) = %v, want +Inf", got)
+ }
+ if got := round(math.Inf(-1), 2); !math.IsInf(got, -1) {
+ t.Errorf("round(-Inf) = %v, want -Inf", got)
+ }
+
+ // 1e18 scaled by 10^4 overflows int64; the value must survive intact.
+ const big = 1e18
+ if got := round(big, 4); got != big {
+ t.Errorf("round(%v, 4) = %v, want %v", big, got, big)
+ }
+}
+
+// Many commands build their payload with json.Marshal and ignore the error.
+// render() must not turn a nil payload into a silent, successful no-op.
+func TestRenderReportsAnEmptyPayloadInsteadOfPrintingNothing(t *testing.T) {
+ for _, data := range [][]byte{nil, {}, []byte(" \n")} {
+ stdout, stderr := captureBoth(func() { render(data) })
+ if strings.TrimSpace(stdout) != "" {
+ t.Errorf("render(%q) wrote to stdout: %q", data, stdout)
+ }
+ if !strings.Contains(stderr, "could not be encoded") {
+ t.Errorf("render(%q) did not report the failure on stderr: %q", data, stderr)
+ }
+ }
+}
diff --git a/go-cli/cmd/optimize_campaign.go b/go-cli/cmd/optimize_campaign.go
index 9d3f8733..77b2b6df 100644
--- a/go-cli/cmd/optimize_campaign.go
+++ b/go-cli/cmd/optimize_campaign.go
@@ -38,7 +38,12 @@ var campaignOptimizeCmd = &cobra.Command{
var sum struct {
Data map[string]interface{} `json:"data"`
}
- _ = json.Unmarshal(sumRaw, &sum)
+ // A discarded parse error left sum.Data nil, and every metric below
+ // coerced to 0 — the command then reported a campaign with real traffic
+ // as having no clicks, leads or revenue.
+ if err := json.Unmarshal(sumRaw, &sum); err != nil {
+ return fmt.Errorf("parsing summary report for campaign %s: %w", id, err)
+ }
s := sum.Data
clicks := toFloat(s["total_clicks"])
leads := toFloat(s["total_leads"])
diff --git a/go-cli/cmd/profile.go b/go-cli/cmd/profile.go
index 67c270d3..30389687 100644
--- a/go-cli/cmd/profile.go
+++ b/go-cli/cmd/profile.go
@@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
+ "os"
"sort"
"strings"
@@ -87,7 +88,7 @@ var configRemoveProfileCmd = &cobra.Command{
force, _ := cmd.Flags().GetBool("force")
if !force && !confirmPrompt("Remove profile %s?", name) {
- fmt.Println("Cancelled.")
+ fmt.Fprintln(os.Stderr, "Cancelled.")
return nil
}
diff --git a/go-cli/cmd/render.go b/go-cli/cmd/render.go
index cc3d778b..728aa779 100644
--- a/go-cli/cmd/render.go
+++ b/go-cli/cmd/render.go
@@ -1,6 +1,9 @@
package cmd
import (
+ "bytes"
+ "fmt"
+ "os"
"strings"
"p202/internal/output"
@@ -26,6 +29,16 @@ func renderOpts() output.Opts {
return opts
}
+// render writes an API payload using the global output flags.
+//
+// An empty payload reaching here means the caller could not build one — several
+// callers assemble theirs with json.Marshal and would otherwise pass nil on
+// failure. Rendering nil prints nothing and exits 0, which reads as "no results"
+// rather than "we failed to encode the results", so report it explicitly.
func render(data []byte) {
+ if len(bytes.TrimSpace(data)) == 0 {
+ fmt.Fprintln(os.Stderr, "Error: no output produced — the response payload could not be encoded.")
+ return
+ }
output.RenderWith(data, renderOpts())
}
diff --git a/go-cli/cmd/report_optimize.go b/go-cli/cmd/report_optimize.go
index 208baf9e..65aa6a64 100644
--- a/go-cli/cmd/report_optimize.go
+++ b/go-cli/cmd/report_optimize.go
@@ -3,6 +3,8 @@ package cmd
import (
"encoding/json"
"fmt"
+ "math"
+ "os"
"sort"
"strconv"
@@ -98,23 +100,26 @@ func breakevenVerdict(leads, cost, margin float64) string {
return "OVER-BID"
}
+// round rounds to the given number of decimal places, half away from zero.
+// It delegates to math.Round rather than casting through int64: that cast is
+// undefined in Go once f*10^places exceeds the int64 range, and it turned NaN or
+// an infinity into an arbitrary finite number instead of preserving it.
func round(f float64, places int) float64 {
- p := 1.0
- for i := 0; i < places; i++ {
- p *= 10
- }
- return float64(int64(f*p+sign(f)*0.5)) / p
-}
-
-func sign(f float64) float64 {
- if f < 0 {
- return -1
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return f
}
- return 1
+ p := math.Pow(10, float64(places))
+ return math.Round(f*p) / p
}
func rowsToJSON(rows []map[string]interface{}) []byte {
- out, _ := json.Marshal(map[string]interface{}{"data": rows})
+ out, err := json.Marshal(map[string]interface{}{"data": rows})
+ if err != nil {
+ // Returning nil here would render as no output at all; render() reports
+ // the empty payload, so add the cause.
+ fmt.Fprintf(os.Stderr, "Error encoding rows for output: %v\n", err)
+ return nil
+ }
return out
}
diff --git a/go-cli/cmd/rotator.go b/go-cli/cmd/rotator.go
index de7bfbd5..f7533f4f 100644
--- a/go-cli/cmd/rotator.go
+++ b/go-cli/cmd/rotator.go
@@ -3,11 +3,9 @@ package cmd
import (
"encoding/json"
"fmt"
- "os"
"strings"
"p202/internal/api"
- "p202/internal/output"
"github.com/spf13/cobra"
)
@@ -87,7 +85,7 @@ var rotatorCreateCmd = &cobra.Command{
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
- return validationError("required flag --name is missing")
+ return fmt.Errorf("required flag --name is missing")
}
body := map[string]interface{}{"name": name}
for _, f := range []string{"default_url", "default_campaign", "default_lp"} {
@@ -121,7 +119,7 @@ var rotatorUpdateCmd = &cobra.Command{
}
}
if len(body) == 0 {
- return validationError("no fields specified; pass at least one flag to update")
+ return fmt.Errorf("no fields specified; pass at least one flag to update")
}
data, err := c.Put("rotators/"+args[0], body)
if err != nil {
@@ -135,76 +133,15 @@ var rotatorUpdateCmd = &cobra.Command{
var rotatorDeleteCmd = &cobra.Command{
Use: "delete ",
Short: "Delete a redirector/rotator and all its routing rules",
- Args: func(cmd *cobra.Command, args []string) error {
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- return cobra.MaximumNArgs(0)(cmd, args)
- }
- return cobra.ExactArgs(1)(cmd, args)
- },
+ Args: deleteArgsValidator,
RunE: func(cmd *cobra.Command, args []string) error {
- c, err := api.NewFromConfig()
- if err != nil {
- return err
- }
- dryRun, _ := cmd.Flags().GetBool("dry-run")
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- idList, parseErr := parseIDList(idsFlag)
- if parseErr != nil {
- return parseErr
- }
- if len(idList) == 0 {
- return validationError("--ids requires at least one ID").WithHint("Comma-separate internal ids, e.g. --ids 12,13,14 (find them with the matching `... list`).")
- }
-
- if dryRun {
- return renderDeletePreviews(c, "rotators", idList)
- }
- if api.StagedMode() {
- return stageDeletes(c, "rotators", idList)
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete %d rotators and all their rules?", len(idList)) {
- fmt.Println("Cancelled.")
- return nil
- }
-
- deleted := 0
- failed := 0
- for _, id := range idList {
- if err := c.Delete("rotators/" + id); err != nil {
- failed++
- fmt.Fprintf(os.Stderr, "Failed to delete rotator %s: %v\n", id, err)
- continue
- }
- deleted++
- }
- output.Success("Deleted %d of %d rotators.", deleted, len(idList))
- if failed > 0 {
- return partialFailureError("failed to delete %d rotators", failed)
- }
- return nil
- }
-
- if dryRun {
- return renderDeletePreviews(c, "rotators", []string{args[0]})
- }
- if api.StagedMode() {
- return stageDeletes(c, "rotators", []string{args[0]})
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force && !confirmPrompt("Delete rotator %s and all its rules?", args[0]) {
- fmt.Println("Cancelled.")
- return nil
- }
- if err := c.Delete("rotators/" + args[0]); err != nil {
- return err
- }
- output.Success("Rotator %s deleted.", args[0])
- return nil
+ return runBulkOrSingleDelete(cmd, args, deleteSpec{
+ endpoint: "rotators",
+ noun: "rotator",
+ plural: "rotators",
+ cascadeOne: " and all its rules",
+ cascadeMany: " and all their rules",
+ })
},
}
@@ -219,7 +156,7 @@ var rotatorRuleCreateCmd = &cobra.Command{
}
ruleName, _ := cmd.Flags().GetString("rule_name")
if ruleName == "" {
- return validationError("required flag --rule_name is missing")
+ return fmt.Errorf("required flag --rule_name is missing")
}
body := map[string]interface{}{
"rule_name": ruleName,
@@ -230,7 +167,7 @@ var rotatorRuleCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetString("criteria_json"); v != "" {
var criteria interface{}
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
- return withHint(fmt.Errorf("invalid --criteria_json: %w", err), "Pass a JSON object; `p202 rotator criteria-values` shows accepted keys and values. Quote it so the shell keeps it intact.")
+ return fmt.Errorf("invalid --criteria_json: %w", err)
}
body["criteria"] = criteria
} else if code, _ := cmd.Flags().GetString("country"); code != "" {
@@ -238,14 +175,14 @@ var rotatorRuleCreateCmd = &cobra.Command{
// never has to know the exact "Name(CC)" value string.
value := countryCriteriaValue(code)
if value == "" {
- return validationError("unknown country code %q", code).WithHint("Find codes with `p202 rotator criteria-values --search `, or pass raw criteria via --criteria_json.")
+ return validationError("unknown country code %q (see `rotator criteria-values --search ...`); or use --criteria_json", code)
}
body["criteria"] = []map[string]string{{"type": "country", "statement": "is", "value": value}}
}
if v, _ := cmd.Flags().GetString("redirects_json"); v != "" {
var redirects interface{}
if err := json.Unmarshal([]byte(v), &redirects); err != nil {
- return withHint(fmt.Errorf("invalid --redirects_json: %w", err), "Pass a JSON array of {url, weight} objects; quote it so the shell keeps it intact.")
+ return fmt.Errorf("invalid --redirects_json: %w", err)
}
body["redirects"] = redirects
} else if camp, _ := cmd.Flags().GetString("redirect-campaign"); camp != "" {
@@ -265,86 +202,19 @@ var rotatorRuleCreateCmd = &cobra.Command{
var rotatorRuleDeleteCmd = &cobra.Command{
Use: "rule-delete ",
Short: "Delete a routing rule from a redirector/rotator",
- Args: func(cmd *cobra.Command, args []string) error {
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- return cobra.ExactArgs(1)(cmd, args)
- }
- return cobra.ExactArgs(2)(cmd, args)
- },
+ Args: deleteArgsValidatorN(1),
RunE: func(cmd *cobra.Command, args []string) error {
- c, err := api.NewFromConfig()
+ rotatorID, err := validateID(args[0])
if err != nil {
return err
}
- dryRun, _ := cmd.Flags().GetBool("dry-run")
- idsFlag, _ := cmd.Flags().GetString("ids")
- if strings.TrimSpace(idsFlag) != "" {
- idList, parseErr := parseIDList(idsFlag)
- if parseErr != nil {
- return parseErr
- }
- if len(idList) == 0 {
- return validationError("--ids requires at least one rule ID").WithHint("Comma-separate rule ids, e.g. --ids 3,4 (find them with `p202 rotator rules `).")
- }
- rotatorID := args[0]
- if dryRun {
- return renderDeletePreviews(c, "rotators/"+rotatorID+"/rules", idList)
- }
- if api.StagedMode() {
- return stageDeletes(c, "rotators/"+rotatorID+"/rules", idList)
- }
- force, _ := cmd.Flags().GetBool("force")
- if !force {
- fmt.Printf("Delete %d rules from rotator %s? [y/N] ", len(idList), rotatorID)
- var answer string
- fmt.Scanln(&answer)
- answer = strings.ToLower(strings.TrimSpace(answer))
- if answer != "y" && answer != "yes" {
- fmt.Println("Cancelled.")
- return nil
- }
- }
-
- deleted := 0
- failed := 0
- for _, ruleID := range idList {
- if err := c.Delete("rotators/" + rotatorID + "/rules/" + ruleID); err != nil {
- failed++
- fmt.Fprintf(os.Stderr, "Failed to delete rule %s from rotator %s: %v\n", ruleID, rotatorID, err)
- continue
- }
- deleted++
- }
- output.Success("Deleted %d of %d rules from rotator %s.", deleted, len(idList), rotatorID)
- if failed > 0 {
- return partialFailureError("failed to delete %d rules", failed)
- }
- return nil
- }
-
- if dryRun {
- return renderDeletePreviews(c, "rotators/"+args[0]+"/rules", []string{args[1]})
- }
- if api.StagedMode() {
- return stageDeletes(c, "rotators/"+args[0]+"/rules", []string{args[1]})
- }
-
- force, _ := cmd.Flags().GetBool("force")
- if !force {
- fmt.Printf("Delete rule %s from rotator %s? [y/N] ", args[1], args[0])
- var answer string
- fmt.Scanln(&answer)
- if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" {
- fmt.Println("Cancelled.")
- return nil
- }
- }
- if err := c.Delete("rotators/" + args[0] + "/rules/" + args[1]); err != nil {
- return err
- }
- output.Success("Rule %s deleted from rotator %s.", args[1], args[0])
- return nil
+ return runBulkOrSingleDelete(cmd, args[1:], deleteSpec{
+ endpoint: "rotators/" + rotatorID + "/rules",
+ noun: "rule",
+ plural: "rules",
+ context: " from rotator " + rotatorID,
+ idsHintText: "Comma-separate rule ids, e.g. --ids 3,4 (find them with `p202 rotator get `).",
+ })
},
}
@@ -369,7 +239,7 @@ var rotatorRuleUpdateCmd = &cobra.Command{
}
ruleID = strings.TrimSpace(ruleID)
if ruleID == "" {
- return validationError("rule id is required (pass it as the second argument or via --rule_id)").WithHint("`p202 rotator rules ` lists rule ids.")
+ return fmt.Errorf("rule id is required (pass it as the second argument or via --rule_id)")
}
body := map[string]interface{}{}
@@ -385,19 +255,19 @@ var rotatorRuleUpdateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetString("criteria_json"); v != "" {
var criteria interface{}
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
- return withHint(fmt.Errorf("invalid --criteria_json: %w", err), "Pass a JSON object; `p202 rotator criteria-values` shows accepted keys and values. Quote it so the shell keeps it intact.")
+ return fmt.Errorf("invalid --criteria_json: %w", err)
}
body["criteria"] = criteria
}
if v, _ := cmd.Flags().GetString("redirects_json"); v != "" {
var redirects interface{}
if err := json.Unmarshal([]byte(v), &redirects); err != nil {
- return withHint(fmt.Errorf("invalid --redirects_json: %w", err), "Pass a JSON array of {url, weight} objects; quote it so the shell keeps it intact.")
+ return fmt.Errorf("invalid --redirects_json: %w", err)
}
body["redirects"] = redirects
}
if len(body) == 0 {
- return validationError("no fields specified; pass at least one flag to update")
+ return fmt.Errorf("no fields specified; pass at least one flag to update")
}
data, err := c.Put("rotators/"+args[0]+"/rules/"+ruleID, body)
diff --git a/go-cli/cmd/shell.go b/go-cli/cmd/shell.go
index df6ed43c..e7e51d82 100644
--- a/go-cli/cmd/shell.go
+++ b/go-cli/cmd/shell.go
@@ -258,7 +258,14 @@ func emitBatchResult(command string, output []byte, err error) {
result["output"] = strings.TrimSpace(string(output))
}
}
- line, _ := json.Marshal(result)
+ line, marshalErr := json.Marshal(result)
+ if marshalErr != nil {
+ // Never drop a batch record silently: a consumer counting JSONL lines
+ // against commands would misread the run as having fewer results.
+ fmt.Fprintf(os.Stderr, "Error encoding result for %q: %v\n", command, marshalErr)
+ fmt.Printf("{\"command\":%q,\"success\":false,\"error\":\"result could not be encoded\"}\n", command)
+ return
+ }
fmt.Println(string(line))
}
@@ -324,14 +331,21 @@ func handleBuiltin(line string, state *shell.State, currentProfile string) (bool
if cmdStr == "" {
return true, "", false, fmt.Errorf("syntax error: assignment to $%s requires a command", varName)
}
- output, err := executeShellCommand(cmdStr)
+ output, err := executeShellCommandWith(cmdStr, true)
if err != nil {
printOutput(output) // partial output produced before the error
return true, "", false, err
}
- if value, ok := normalizeValue(output); ok {
- state.Set(varName, value)
+ value, ok := normalizeValue(output)
+ if !ok {
+ // Captured as JSON above, so empty stdout is unambiguous here: a
+ // void operation (delete, revoke) that reports success on stderr
+ // and has no result to store. An empty result SET is {"data":[]}
+ // and lands in state.Set below. Silently leaving $name unset
+ // would let the user believe the result was captured.
+ return true, "", false, fmt.Errorf("command produced no output to capture; $%s was not set", varName)
}
+ state.Set(varName, value)
printOutput(output)
return true, "", false, nil
}
@@ -424,6 +438,21 @@ func currentProfileName() string {
// produced before the failure is returned alongside the error; the caller
// decides how to surface it (printing it here would corrupt JSONL output).
func executeShellCommand(line string) ([]byte, error) {
+ return executeShellCommandWith(line, false)
+}
+
+// executeShellCommandWith runs a shell line, optionally forcing JSON output.
+//
+// forceJSON exists for `$name = `. In the session's default table
+// mode a list with zero rows writes NOTHING to stdout — renderTable sends
+// "No results." to stderr — so an empty capture is indistinguishable from a
+// void operation that has no result at all. Forcing JSON removes the
+// ambiguity at the source rather than making the assignment guess: an empty
+// result set becomes {"data":[]} and is stored, while a genuine void
+// operation still writes nothing and is reported. Variables hold JSON
+// anyway ($name pretty-prints the stored value), so this is also the format
+// the capture is for.
+func executeShellCommandWith(line string, forceJSON bool) ([]byte, error) {
tokens, err := shell.TokenizeLine(line)
if err != nil {
return nil, fmt.Errorf("parse error: %w", err)
@@ -450,10 +479,15 @@ func executeShellCommand(line string) ([]byte, error) {
// it would only propose. PersistentPreRunE reads this variable on every
// Execute(), so it has to be restored like the others.
savedStaged := stagedWrites
+ // The command path the top-level error envelope and its hint name.
+ // PersistentPreRunE re-stamps this for every inner command, so without
+ // restoring it a failing `p202 shell` reports the LAST command the batch
+ // ran and points an agent at that command's --help instead of its own.
+ savedCommandPath := activeCommandPath
sessionOverride := configpkg.GetActiveOverride()
resetAllFlags(rootCmd)
- jsonOutput = savedJSON
+ jsonOutput = savedJSON || forceJSON
csvOutput = savedCSV
profileName = savedProfile
groupName = savedGroup
@@ -473,6 +507,7 @@ func executeShellCommand(line string) ([]byte, error) {
})
// Restore session-level state the command's own flags may have modified.
+ activeCommandPath = savedCommandPath
jsonOutput = savedJSON
csvOutput = savedCSV
profileName = savedProfile
@@ -507,12 +542,19 @@ func captureStdout(fn func()) []byte {
done <- buf
}()
- fn()
-
- os.Stdout = oldStdout
- _ = w.Close()
- captured := <-done
- _ = r.Close()
+ // Restore through a defer: if fn panics, leaving os.Stdout pointing at this
+ // pipe would silence every later command in the session and leak the reader
+ // goroutine. The panic still propagates after the restore runs.
+ var captured []byte
+ func() {
+ defer func() {
+ os.Stdout = oldStdout
+ _ = w.Close()
+ captured = <-done
+ _ = r.Close()
+ }()
+ fn()
+ }()
return captured
}
@@ -545,11 +587,15 @@ func normalizeValue(output []byte) (json.RawMessage, bool) {
return json.RawMessage(quoted), true
}
-// storeResult saves command output as the $_ variable.
+// storeResult saves command output as the $_ variable. A command that produced
+// no output sets $_ to null rather than leaving the previous command's value in
+// place, which would misreport stale data as the last result.
func storeResult(state *shell.State, output []byte) {
if value, ok := normalizeValue(output); ok {
state.SetLast(value)
+ return
}
+ state.SetLast(json.RawMessage("null"))
}
func init() {
diff --git a/go-cli/cmd/shell_capture_test.go b/go-cli/cmd/shell_capture_test.go
new file mode 100644
index 00000000..882f8dcd
--- /dev/null
+++ b/go-cli/cmd/shell_capture_test.go
@@ -0,0 +1,138 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "p202/internal/shell"
+)
+
+func emptyListServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if strings.HasSuffix(r.URL.Path, "/capabilities") {
+ // `p202 shell` is gated on this capability.
+ _, _ = w.Write([]byte(`{"data":{"shell":true}}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"data":[],"pagination":{"total":0,"limit":50,"offset":0}}`))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// A list that legitimately matches nothing is a successful command with an
+// empty result, not a failure. In the shell's default table mode it writes
+// nothing to stdout ("No results." goes to stderr), which made it
+// indistinguishable from a void operation and failed the assignment — under
+// --stop-on-error that aborted the whole batch.
+func TestAssignmentCapturesAnEmptyResultSet(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := emptyListServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ state := shell.NewState()
+ handled, _, _, err := handleBuiltin("$rows = campaign list", state, "default")
+ if !handled {
+ t.Fatal("assignment should be handled as a builtin")
+ }
+ if err != nil {
+ t.Fatalf("an empty result set is not an error: %v", err)
+ }
+
+ raw, ok := state.Get("rows")
+ if !ok {
+ t.Fatal("$rows was not set")
+ }
+ var parsed struct {
+ Data []interface{} `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ t.Fatalf("stored value is not the JSON envelope: %v (%q)", err, raw)
+ }
+ if len(parsed.Data) != 0 {
+ t.Fatalf("expected an empty data array, got %v", parsed.Data)
+ }
+}
+
+// The batch must not abort on it either — that was the reported failure.
+func TestBatchWithStopOnErrorSurvivesAnEmptyResultSet(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := emptyListServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ if _, _, err := executeCommand("shell", "--stop-on-error", "-c", "$x = campaign list; campaign list"); err != nil {
+ t.Fatalf("batch aborted on a legitimately empty result: %v", err)
+ }
+}
+
+// The original defect stays fixed: a void operation writes nothing to stdout
+// even as JSON, so there is genuinely nothing to capture and the user must be
+// told rather than left believing $name holds the result.
+func TestAssignmentFromAVoidOperationStillReports(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := newRecordingServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ state := shell.NewState()
+ _, _, _, err := handleBuiltin("$gone = campaign delete 7 --force", state, "default")
+ if err == nil {
+ t.Fatal("expected an error explaining that $gone was not set")
+ }
+ if !strings.Contains(err.Error(), "$gone") {
+ t.Fatalf("error should name the variable, got %q", err)
+ }
+ if _, ok := state.Get("gone"); ok {
+ t.Fatal("$gone must not be set when there was nothing to capture")
+ }
+}
+
+// Forcing JSON is scoped to the capture: it must not leak into the session's
+// display mode for subsequent commands.
+func TestAssignmentDoesNotChangeTheSessionOutputMode(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := emptyListServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ before := jsonOutput
+ state := shell.NewState()
+ if _, _, _, err := handleBuiltin("$rows = campaign list", state, "default"); err != nil {
+ t.Fatalf("assignment: %v", err)
+ }
+ if jsonOutput != before {
+ t.Fatalf("session jsonOutput changed from %v to %v", before, jsonOutput)
+ }
+}
+
+// activeCommandPath is a package global that PersistentPreRunE re-stamps on
+// every in-process execution. Without restoring it across the shell's
+// re-entry, a failing `p202 shell` reports the last command the batch ran, and
+// the hint sends an agent to that command's --help instead of its own.
+func TestShellErrorEnvelopeNamesTheShellNotTheLastInnerCommand(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := emptyListServer(t)
+ writeTestConfig(t, home, srv.URL, "test-api-key-1234")
+
+ stdout, _, err := executeCommand("shell", "--json", "-c", "campaign list; campaign delete abc --force")
+ if err == nil {
+ t.Fatal("expected the batch to fail on the invalid id")
+ }
+
+ // The envelope is printed by Execute(), which is not reached from a test;
+ // assert on the state Execute() would read.
+ if activeCommandPath != "" && !strings.Contains(activeCommandPath, "shell") {
+ t.Fatalf("activeCommandPath = %q, want it to name the shell (stdout: %q)", activeCommandPath, stdout)
+ }
+ if hint := hintFor(err); strings.Contains(hint, "campaign delete") {
+ t.Fatalf("hint points at the inner command: %q", hint)
+ }
+}
diff --git a/go-cli/cmd/shell_semantics_test.go b/go-cli/cmd/shell_semantics_test.go
new file mode 100644
index 00000000..755a9990
--- /dev/null
+++ b/go-cli/cmd/shell_semantics_test.go
@@ -0,0 +1,68 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "p202/internal/shell"
+)
+
+func osStdout() *os.File { return os.Stdout }
+
+func print_(s string) { fmt.Print(s) }
+
+// $_ is documented as the last result. After a command that produced no output
+// it used to retain the previous command's value and report it as current.
+func TestLastResultIsClearedWhenACommandProducesNoOutput(t *testing.T) {
+ state := shell.NewState()
+
+ storeResult(state, []byte(`{"data":[{"id":1}]}`))
+ first, ok := state.Get("_")
+ if !ok || !strings.Contains(string(first), `"id"`) {
+ t.Fatalf("first result not stored: %q", first)
+ }
+
+ storeResult(state, nil)
+ after, ok := state.Get("_")
+ if !ok {
+ t.Fatal("$_ should still exist after a command with no output")
+ }
+ if strings.Contains(string(after), `"id"`) {
+ t.Fatalf("$_ still holds the previous command's output: %q", after)
+ }
+ var parsed interface{}
+ if err := json.Unmarshal(after, &parsed); err != nil {
+ t.Fatalf("$_ should be valid JSON, got %q", after)
+ }
+ if parsed != nil {
+ t.Fatalf("$_ = %v, want null", parsed)
+ }
+}
+
+// captureStdout must restore os.Stdout even when the wrapped function panics;
+// otherwise every later command in the shell session writes into a closed pipe.
+func TestCaptureStdoutRestoresStdoutOnPanic(t *testing.T) {
+ before := osStdout()
+
+ func() {
+ defer func() {
+ if recover() == nil {
+ t.Error("panic should propagate to the caller")
+ }
+ }()
+ captureStdout(func() { panic("boom") })
+ }()
+
+ if osStdout() != before {
+ t.Fatal("os.Stdout was not restored after a panic")
+ }
+
+ // And capturing must still work afterwards.
+ got := captureStdout(func() { print_("still works") })
+ if strings.TrimSpace(string(got)) != "still works" {
+ t.Fatalf("capture broken after panic: %q", got)
+ }
+}
diff --git a/go-cli/cmd/sync.go b/go-cli/cmd/sync.go
index d0be6e13..7589c953 100644
--- a/go-cli/cmd/sync.go
+++ b/go-cli/cmd/sync.go
@@ -363,7 +363,10 @@ func runSyncProfiles(entities []string, fromProfile, toProfile string, opts sync
if opts.Incremental && manifest != nil && sourceID != "" {
if entry, exists := manifest.GetMapping(currentEntity, sourceID); exists {
- if entry.SourceHash == sourceHash {
+ // "" means no fingerprint (see comparableHash); it must never
+ // satisfy the unchanged-skip, else unencodable rows are
+ // silently dropped from every incremental sync.
+ if entry.SourceHash != "" && entry.SourceHash == sourceHash {
result.Skipped++
idMap.Set(currentEntity, sourceID, entry.TargetID)
continue
@@ -646,8 +649,17 @@ func handleSyncRecordError(entity, key string, err error, skipErrors bool, resul
return skipErrors
}
+// comparableHash fingerprints a record for incremental-sync change detection.
+// A row that cannot be encoded returns "" — never a real hash — because hashing
+// the nil bytes from a failed Marshal gave every unencodable row the SAME
+// digest, so a changed record could match its stored hash and be skipped as
+// unchanged. Callers must treat "" as "no fingerprint" (see the skip check in
+// runSyncProfiles), which errs toward re-syncing.
func comparableHash(row map[string]interface{}) string {
- data, _ := json.Marshal(row)
+ data, err := json.Marshal(row)
+ if err != nil {
+ return ""
+ }
sum := sha1.Sum(data)
return hex.EncodeToString(sum[:])
}
@@ -793,11 +805,14 @@ func tryServerSyncRead(path, fromProfile, toProfile string) (bool, error) {
return false, nil //nolint:nilerr // probe-and-fall-back: a setup failure means server-side sync is unavailable, so the caller runs the client-side path
}
+ // scalarString instead of type assertions: loadProfileConnection builds
+ // string values today, but a bare .(string) here would panic at a distance
+ // if that map's shape ever changed in diff.go.
params := map[string]string{
- "source[name]": sourceConn["name"].(string),
- "source[url]": sourceConn["url"].(string),
- "target[name]": targetConn["name"].(string),
- "target[url]": targetConn["url"].(string),
+ "source[name]": scalarString(sourceConn["name"]),
+ "source[url]": scalarString(sourceConn["url"]),
+ "target[name]": scalarString(targetConn["name"]),
+ "target[url]": scalarString(targetConn["url"]),
}
resp, err := orchestrator.Get(path, params)
if err != nil {
diff --git a/go-cli/cmd/user.go b/go-cli/cmd/user.go
index 49365f8e..2224e78e 100644
--- a/go-cli/cmd/user.go
+++ b/go-cli/cmd/user.go
@@ -195,11 +195,11 @@ var userRoleAssignCmd = &cobra.Command{
}
roleIDStr := roleIDFrom(cmd, args)
if roleIDStr == "" {
- return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user roles` lists role ids.")
+ return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user role list` lists role ids.")
}
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
- return validationError("role_id must be an integer: %s", roleIDStr).WithHint("`p202 user roles` lists role ids.")
+ return validationError("role_id must be an integer: %s", roleIDStr).WithHint("`p202 user role list` lists role ids.")
}
data, err := c.Post("users/"+args[0]+"/roles", map[string]interface{}{
"role_id": roleID,
@@ -223,7 +223,7 @@ var userRoleRemoveCmd = &cobra.Command{
}
roleID := roleIDFrom(cmd, args)
if roleID == "" {
- return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user roles` lists role ids.")
+ return validationError("role id is required (pass it as the second argument or via --role_id)").WithHint("`p202 user role list` lists role ids.")
}
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
return renderDeletePreviews(c, "users/"+args[0]+"/roles", []string{roleID})
@@ -340,14 +340,9 @@ var userAPIKeyDeleteCmd = &cobra.Command{
return stageDeletes(c, "users/"+args[0]+"/api-keys", []string{args[1]})
}
force, _ := cmd.Flags().GetBool("force")
- if !force {
- fmt.Printf("Delete API key for user %s? [y/N] ", args[0])
- var answer string
- fmt.Scanln(&answer)
- if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" {
- fmt.Println("Cancelled.")
- return nil
- }
+ if !force && !confirmPrompt("Delete API key for user %s?", args[0]) {
+ fmt.Fprintln(os.Stderr, "Cancelled.")
+ return nil
}
if err := c.Delete("users/" + args[0] + "/api-keys/" + args[1]); err != nil {
return err
@@ -451,11 +446,11 @@ var userAPIKeyRotateCmd = &cobra.Command{
deletedOld := false
if !keepOld {
if !force {
- fmt.Printf("Delete old API key for user %s? [y/N] ", userID)
- var answer string
- fmt.Scanln(&answer)
- if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" {
- fmt.Println("Skipping old key deletion.")
+ // Prompt via the shared helper so the question and the outcome go
+ // to stderr; this command renders a JSON result on stdout, which
+ // the prompt text used to corrupt.
+ if !confirmPrompt("Delete old API key for user %s?", userID) {
+ fmt.Fprintln(os.Stderr, "Skipping old key deletion.")
} else {
if err := c.Delete("users/" + userID + "/api-keys/" + oldAPIKey); err != nil {
return err
@@ -472,13 +467,23 @@ var userAPIKeyRotateCmd = &cobra.Command{
configUpdated := false
configUpdateSkipped := false
+ configProfile := ""
if updateConfig {
cfg, err := configpkg.Load()
if err != nil {
return err
}
- if cfg.APIKey == oldAPIKey || forceConfigUpdate {
- cfg.APIKey = newAPIKey
+ // Compare and write through the resolved profile. This used to read
+ // the legacy top-level cfg.APIKey, which is empty for every
+ // profile-based config, so the match never fired and --update-config
+ // was a no-op unless --force-config-update was also passed.
+ p, resolvedName, err := cfg.EnsureProfile(profileName)
+ if err != nil {
+ return err
+ }
+ configProfile = resolvedName
+ if p.APIKey == oldAPIKey || forceConfigUpdate {
+ p.APIKey = newAPIKey
if err := cfg.Save(); err != nil {
return err
}
@@ -500,6 +505,7 @@ var userAPIKeyRotateCmd = &cobra.Command{
"old_key_kept": keepOld || !deletedOld,
"config_updated": configUpdated,
"config_update_skipped": configUpdateSkipped,
+ "config_profile": configProfile,
}
encoded, _ := json.Marshal(out)
render(encoded)
diff --git a/go-cli/cmd/verify.go b/go-cli/cmd/verify.go
index e0e930e9..2b9fbea7 100644
--- a/go-cli/cmd/verify.go
+++ b/go-cli/cmd/verify.go
@@ -356,6 +356,7 @@ var rotatorCheckCmd = &cobra.Command{
return err
}
var datas []map[string]interface{}
+ var fetchFailures []map[string]interface{}
if len(args) == 1 {
raw, err := c.Get("rotators/"+args[0], nil)
if err != nil {
@@ -382,19 +383,35 @@ var rotatorCheckCmd = &cobra.Command{
for _, r := range resp.Data {
full, err := c.Get(fmt.Sprintf("rotators/%v", normalizeID(r["id"])), nil)
if err != nil {
+ // This command gates deploys, so a rotator whose detail
+ // fetch failed must count as a failure — silently skipping
+ // it let a broken rotator ride an exit code 0.
+ fetchFailures = append(fetchFailures, map[string]interface{}{
+ "id": normalizeID(r["id"]),
+ "name": r["name"],
+ "status": "ERROR",
+ "reason": fmt.Sprintf("could not fetch rotator: %v", err),
+ })
continue
}
var fr struct {
Data map[string]interface{} `json:"data"`
}
- if json.Unmarshal(full, &fr) == nil {
- datas = append(datas, fr.Data)
+ if err := json.Unmarshal(full, &fr); err != nil {
+ fetchFailures = append(fetchFailures, map[string]interface{}{
+ "id": normalizeID(r["id"]),
+ "name": r["name"],
+ "status": "ERROR",
+ "reason": fmt.Sprintf("could not parse rotator: %v", err),
+ })
+ continue
}
+ datas = append(datas, fr.Data)
}
}
- rows := make([]map[string]interface{}, 0, len(datas))
- failed := 0
+ rows := make([]map[string]interface{}, 0, len(datas)+len(fetchFailures))
+ failed := len(fetchFailures)
for _, d := range datas {
issues := rotatorIssues(d)
status := "OK"
@@ -409,6 +426,7 @@ var rotatorCheckCmd = &cobra.Command{
"reason": strings.Join(issues, "; "),
})
}
+ rows = append(rows, fetchFailures...)
render(rowsToJSON(rows))
if failed > 0 {
return partialFailureError("%d rotator(s) have configuration issues", failed)
@@ -443,7 +461,12 @@ var rotatorTraceCmd = &cobra.Command{
var tr struct {
Data []map[string]interface{} `json:"data"`
}
- _ = json.Unmarshal(trk, &tr)
+ // Discarding this error made tr.Data nil, so a malformed response was
+ // reported as "fed by 0 tracker(s)" — a confident false claim from a
+ // command whose whole purpose is verification.
+ if err := json.Unmarshal(trk, &tr); err != nil {
+ return fmt.Errorf("parsing trackers for rotator %s: %w", args[0], err)
+ }
fmt.Fprintf(os.Stderr, "Rotator %s %q — default %s, %d rule(s), fed by %d tracker(s)\n",
args[0], fmt.Sprintf("%v", rot.Data["name"]), defaultDest(rot.Data),
@@ -534,7 +557,11 @@ var trackerCheckCmd = &cobra.Command{
var resp struct {
Data []map[string]interface{} `json:"data"`
}
- _ = json.Unmarshal(list, &resp)
+ // Without this check a malformed list left resp.Data nil, so the
+ // command verified nothing at all and still reported success.
+ if err := json.Unmarshal(list, &resp); err != nil {
+ return fmt.Errorf("parsing tracker list: %w", err)
+ }
for _, t := range resp.Data {
ids = append(ids, fmt.Sprintf("%v", normalizeID(t["tracker_id"])))
}
diff --git a/go-cli/go.mod b/go-cli/go.mod
index b9b1dd55..85deb97c 100644
--- a/go-cli/go.mod
+++ b/go-cli/go.mod
@@ -4,11 +4,11 @@ go 1.22
require (
github.com/spf13/cobra v1.8.1
+ golang.org/x/sys v0.28.0
golang.org/x/term v0.27.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
- golang.org/x/sys v0.28.0 // indirect
)
diff --git a/go-cli/internal/api/client.go b/go-cli/internal/api/client.go
index cd49fb59..6a4305c2 100644
--- a/go-cli/internal/api/client.go
+++ b/go-cli/internal/api/client.go
@@ -8,7 +8,9 @@ import (
"io"
"net/http"
"net/url"
+ "regexp"
"strings"
+ "sync"
"time"
"p202/internal/config"
@@ -37,15 +39,28 @@ func StagedMode() bool {
type Client struct {
rootURL string
- baseURL string
apiKey string
http *http.Client
+ // mu guards every field below it. A Client is shared across goroutines
+ // (cmd/crud.go fans bulk tracker-URL fetches over a worker pool with one
+ // client; cmd/shell.go keeps a long-lived one), and ensureCapabilities()
+ // lazily rewrites baseURL after version negotiation while in-flight
+ // requests are reading it — an unsynchronized read/write pair.
+ mu sync.Mutex
+ baseURL string
capabilities map[string]interface{}
capabilitiesLoaded bool
capabilitiesErr error
}
+// currentBaseURL returns the negotiated base URL under the lock.
+func (c *Client) currentBaseURL() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.baseURL
+}
+
type APIError struct {
Status int
Message string
@@ -132,7 +147,7 @@ func HintFor(err error) string {
case apiErr.Status == 403 && strings.Contains(strings.ToLower(apiErr.Message), "scope"):
return "This key's scope does not cover the operation. Use a key with the needed scope, or mint one: `p202 user apikey create --scope write` (scopes: *, read, write, :read, :write)."
case apiErr.Status == 401 || apiErr.Status == 403:
- return "Verify your API key: run `p202 config get`, then `p202 config set-key ` if it's wrong."
+ return "Verify your API key: run `p202 config show`, then `p202 config set-key ` if it's wrong."
case apiErr.Status == 404:
return "Not found. Run the matching `... list` to find valid ids (ids are internal — not the public ones in tracking links; some commands accept --public)."
// A 409 has several unrelated causes -- a still-running idempotent
@@ -167,7 +182,7 @@ func HintFor(err error) string {
if errors.As(err, &reqErr) {
switch reqErr.Kind {
case "network":
- return "Check the server URL (`p202 config get`) and that the instance is reachable; run `p202 config test` to verify the connection."
+ return "Check the server URL (`p202 config show`) and that the instance is reachable; run `p202 config test` to verify the connection."
case "validation":
return "The request could not be built from the given values; check them and retry."
}
@@ -237,10 +252,13 @@ func (c *Client) SupportsCapability(path ...string) bool {
func (c *Client) Capability(path ...string) (interface{}, bool) {
c.ensureCapabilities()
+ c.mu.Lock()
+ caps := c.capabilities
+ c.mu.Unlock()
if len(path) == 0 {
- return c.capabilities, len(c.capabilities) > 0
+ return caps, len(caps) > 0
}
- var current interface{} = c.capabilities
+ var current interface{} = caps
for _, key := range path {
obj, ok := current.(map[string]interface{})
if !ok {
@@ -260,16 +278,25 @@ func (c *Client) Capability(path ...string) (interface{}, bool) {
// does not grant this capability" from "the capabilities could not be fetched".
func (c *Client) CapabilitiesError() error {
c.ensureCapabilities()
+ c.mu.Lock()
+ defer c.mu.Unlock()
return c.capabilitiesErr
}
+// ensureCapabilities loads capabilities at most once. It holds mu for the whole
+// negotiate-and-load sequence so concurrent callers see either the pre- or the
+// post-negotiation baseURL, never a torn read, and only one of them performs
+// the network round-trips.
func (c *Client) ensureCapabilities() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
if c.capabilitiesLoaded {
return
}
c.capabilitiesLoaded = true
- c.negotiateVersion()
+ c.negotiateVersionLocked()
req, err := http.NewRequest("GET", c.baseURL+"/capabilities", nil)
if err != nil {
@@ -310,7 +337,14 @@ func (c *Client) ensureCapabilities() {
c.capabilities = decoded
}
-func (c *Client) negotiateVersion() {
+// apiVersionPattern constrains the version segment the SERVER hands back. It is
+// interpolated straight into every subsequent request path, so anything other
+// than digits (a traversal like "3/../../admin", a query string, a stray space)
+// must not be accepted from a remote response.
+var apiVersionPattern = regexp.MustCompile(`^[0-9]{1,4}$`)
+
+// negotiateVersionLocked must be called with c.mu held.
+func (c *Client) negotiateVersionLocked() {
req, err := http.NewRequest("GET", c.rootURL+"/api/versions", nil)
if err != nil {
return
@@ -348,7 +382,9 @@ func (c *Client) negotiateVersion() {
}
preferred = strings.TrimPrefix(strings.ToLower(preferred), "v")
- if preferred == "" {
+ if !apiVersionPattern.MatchString(preferred) {
+ // Keep the compiled-in default rather than trusting a malformed or
+ // hostile version string from the server.
return
}
c.baseURL = c.rootURL + "/api/v" + preferred
@@ -436,7 +472,10 @@ func (c *Client) do(method, path string, params map[string]string, body interfac
}
func (c *Client) doWithHeaders(method, path string, params map[string]string, body interface{}, headers map[string]string) ([]byte, error) {
- u := c.baseURL + "/" + strings.TrimLeft(path, "/")
+ // Read once under the lock: version negotiation can rewrite baseURL from
+ // another goroutine, and the URL and the version header below must agree.
+ baseURL := c.currentBaseURL()
+ u := baseURL + "/" + strings.TrimLeft(path, "/")
if stagedMode &&
(method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") &&
@@ -481,8 +520,8 @@ func (c *Client) doWithHeaders(method, path string, params map[string]string, bo
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "p202-cli/2.0 (Go)")
- if idx := strings.LastIndex(c.baseURL, "/api/v"); idx != -1 {
- req.Header.Set("X-P202-API-Version", c.baseURL[idx+5:])
+ if idx := strings.LastIndex(baseURL, "/api/v"); idx != -1 {
+ req.Header.Set("X-P202-API-Version", baseURL[idx+5:])
}
for name, value := range headers {
req.Header.Set(name, value)
diff --git a/go-cli/internal/api/client_concurrency_test.go b/go-cli/internal/api/client_concurrency_test.go
new file mode 100644
index 00000000..7e0f99ed
--- /dev/null
+++ b/go-cli/internal/api/client_concurrency_test.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Client is shared across goroutines by callers (cmd/crud.go fans out bulk
+// tracker-URL fetches over a worker pool with one client, and cmd/shell.go
+// keeps a long-lived client). ensureCapabilities() lazily MUTATES baseURL,
+// capabilities, capabilitiesLoaded and capabilitiesErr, while do() reads
+// baseURL on every request — so a capability lookup racing a request is a
+// data race on the same fields.
+//
+// Run with -race; this fails loudly if the guard around that state regresses.
+func TestConcurrentRequestsAndCapabilityLookupsAreRaceFree(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/versions":
+ _, _ = w.Write([]byte(`{"data":{"preferred":"v3"}}`))
+ case "/api/v3/capabilities":
+ _, _ = w.Write([]byte(`{"data":{"bulk":{"enabled":true}}}`))
+ default:
+ _, _ = w.Write([]byte(`{"data":{"ok":true}}`))
+ }
+ }))
+ defer srv.Close()
+
+ c := newClient(srv.URL, "test-api-key-1234")
+
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if _, err := c.Get("trackers/1/url", nil); err != nil {
+ t.Errorf("Get: %v", err)
+ }
+ }()
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.SupportsCapability("bulk", "enabled")
+ }()
+ }
+ wg.Wait()
+}
+
+// The version segment from /api/versions is interpolated into every subsequent
+// request path, so a hostile or buggy server must not be able to steer it.
+func TestNegotiateVersionRejectsNonNumericVersions(t *testing.T) {
+ cases := []struct {
+ name string
+ preferred string
+ wantPath string // expected path prefix used for the capabilities call
+ }{
+ {"numeric is accepted", "v4", "/api/v4/"},
+ {"traversal is rejected", "3/../../admin", "/api/v3/"},
+ {"query injection is rejected", "3?evil=1", "/api/v3/"},
+ {"garbage is rejected", "not-a-version", "/api/v3/"},
+ {"empty is rejected", "", "/api/v3/"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var capabilitiesPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/api/versions" {
+ _, _ = w.Write([]byte(`{"data":{"preferred":"` + tc.preferred + `"}}`))
+ return
+ }
+ if strings.HasSuffix(r.URL.Path, "/capabilities") {
+ capabilitiesPath = r.URL.Path
+ }
+ _, _ = w.Write([]byte(`{"data":{}}`))
+ }))
+ defer srv.Close()
+
+ c := newClient(srv.URL, "test-api-key-1234")
+ c.ensureCapabilities()
+
+ if !strings.HasPrefix(capabilitiesPath, tc.wantPath) {
+ t.Fatalf("capabilities requested %q, want prefix %q", capabilitiesPath, tc.wantPath)
+ }
+ })
+ }
+}
diff --git a/go-cli/internal/atomicfile/atomicfile.go b/go-cli/internal/atomicfile/atomicfile.go
new file mode 100644
index 00000000..55dc2e8f
--- /dev/null
+++ b/go-cli/internal/atomicfile/atomicfile.go
@@ -0,0 +1,70 @@
+// Package atomicfile writes a file's full contents in one all-or-nothing step.
+//
+// It exists because the CLI keeps two things under ~/.p202 that must never be
+// observed half-written: the config file holding the API key, and the sync
+// manifest that decides what an incremental sync will skip. A plain
+// os.WriteFile is wrong for both — it truncates in place, so a crash mid-write
+// leaves a corrupt file, and its permission argument applies only when it
+// creates the file, so a file that already exists with looser permissions keeps
+// them forever.
+package atomicfile
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Write creates data at path with the given permissions, atomically.
+//
+// The write goes to a temp file in the same directory (same filesystem, so the
+// rename cannot fail with EXDEV), is flushed to disk, and is then renamed over
+// path. Rename replaces the destination directory entry, which also means a
+// symlink planted at path is replaced rather than written through. On any
+// failure the temp file is removed and path is left untouched.
+func Write(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
+ if err != nil {
+ return fmt.Errorf("creating temp file in %s: %w", dir, err)
+ }
+ tmpName := tmp.Name()
+ abandon := func(verb string, cause error) error {
+ _ = tmp.Close()
+ _ = os.Remove(tmpName)
+ return fmt.Errorf("%s %s: %w", verb, tmpName, cause)
+ }
+
+ // os.CreateTemp already uses 0600, but the caller's intent is what must hold
+ // on the final file, so set it explicitly rather than inheriting a default.
+ if err := tmp.Chmod(perm); err != nil {
+ return abandon("setting permissions on", err)
+ }
+ if _, err := tmp.Write(data); err != nil {
+ return abandon("writing", err)
+ }
+ // Flush before the rename: without it a crash can leave the renamed entry
+ // pointing at unwritten data.
+ if err := tmp.Sync(); err != nil {
+ return abandon("flushing", err)
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpName)
+ return fmt.Errorf("closing %s: %w", tmpName, err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ _ = os.Remove(tmpName)
+ return fmt.Errorf("renaming %s to %s: %w", tmpName, path, err)
+ }
+
+ // Flushing the file's contents is not enough: the rename itself is a
+ // directory-entry change, and on ext4/XFS that entry can be absent after a
+ // crash even though tmp.Sync() returned. Without this the documented
+ // all-or-nothing guarantee silently does not hold — `config set-key` could
+ // report success and still leave the old key, and a lost sync-manifest
+ // rename makes the next incremental sync re-create every already-synced
+ // record. Best-effort: Windows cannot open a directory for sync, so a
+ // failure here is not fatal to a write that has already landed.
+ syncDir(dir)
+ return nil
+}
diff --git a/go-cli/internal/atomicfile/atomicfile_test.go b/go-cli/internal/atomicfile/atomicfile_test.go
new file mode 100644
index 00000000..e4a3b8c8
--- /dev/null
+++ b/go-cli/internal/atomicfile/atomicfile_test.go
@@ -0,0 +1,141 @@
+package atomicfile
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestWriteCreatesFileWithContentAndMode(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+
+ if err := Write(path, []byte("hello\n"), 0600); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "hello\n" {
+ t.Fatalf("content = %q, want %q", got, "hello\n")
+ }
+ if runtime.GOOS != "windows" {
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := info.Mode().Perm(); mode != 0600 {
+ t.Fatalf("mode = %04o, want 0600", mode)
+ }
+ }
+}
+
+// os.WriteFile's mode argument applies only on creation, so an existing file
+// with looser permissions kept them. Write must enforce the mode every time.
+func TestWriteTightensModeOnExistingFile(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX permission bits are not meaningful on Windows")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+
+ if err := os.WriteFile(path, []byte("old"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := Write(path, []byte("new"), 0600); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := info.Mode().Perm(); mode != 0600 {
+ t.Fatalf("mode = %04o, want 0600", mode)
+ }
+}
+
+// A symlink planted at the destination must be replaced, not written through —
+// otherwise an API key could be redirected into an attacker-readable file.
+func TestWriteReplacesSymlinkInsteadOfFollowingIt(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink creation needs elevation on Windows")
+ }
+ dir := t.TempDir()
+ outside := filepath.Join(dir, "outside.txt")
+ if err := os.WriteFile(outside, []byte("untouched"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "config.json")
+ if err := os.Symlink(outside, path); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Write(path, []byte("secret"), 0600); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ victim, err := os.ReadFile(outside)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(victim) != "untouched" {
+ t.Fatalf("symlink was followed: target now %q", victim)
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ t.Fatal("destination is still a symlink")
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "secret" {
+ t.Fatalf("content = %q, want %q", got, "secret")
+ }
+}
+
+func TestWriteLeavesNoTempFilesBehind(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+
+ for i := 0; i < 3; i++ {
+ if err := Write(path, []byte("data"), 0600); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.HasSuffix(e.Name(), ".tmp") {
+ t.Fatalf("temp file left behind: %s", e.Name())
+ }
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected exactly the target file, got %d entries", len(entries))
+ }
+}
+
+// A failure must leave the previous contents intact rather than a truncated file.
+func TestWriteFailureLeavesDestinationIntact(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sub", "config.json")
+
+ // The parent directory does not exist, so the temp create fails.
+ if err := Write(path, []byte("data"), 0600); err == nil {
+ t.Fatal("expected an error writing into a missing directory")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("destination should not exist, stat err = %v", err)
+ }
+}
diff --git a/go-cli/internal/atomicfile/syncdir_unix.go b/go-cli/internal/atomicfile/syncdir_unix.go
new file mode 100644
index 00000000..7b2bb310
--- /dev/null
+++ b/go-cli/internal/atomicfile/syncdir_unix.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package atomicfile
+
+import "os"
+
+// syncDir fsyncs a directory so a rename into it is durable.
+func syncDir(dir string) {
+ d, err := os.Open(dir)
+ if err != nil {
+ return
+ }
+ _ = d.Sync()
+ _ = d.Close()
+}
diff --git a/go-cli/internal/atomicfile/syncdir_windows.go b/go-cli/internal/atomicfile/syncdir_windows.go
new file mode 100644
index 00000000..e768c9ae
--- /dev/null
+++ b/go-cli/internal/atomicfile/syncdir_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package atomicfile
+
+// syncDir is a no-op on Windows: a directory handle cannot be opened for
+// synchronisation the way it can on POSIX. MoveFileEx already replaces the
+// destination entry atomically, so the rename is not observed half-applied;
+// only the flush-to-platter ordering guarantee is unavailable.
+func syncDir(string) {}
diff --git a/go-cli/internal/config/config.go b/go-cli/internal/config/config.go
index 4fe9a6a0..7c88f7b0 100644
--- a/go-cli/internal/config/config.go
+++ b/go-cli/internal/config/config.go
@@ -8,6 +8,8 @@ import (
"sort"
"strings"
"sync"
+
+ "p202/internal/atomicfile"
)
const defaultProfileName = "default"
@@ -58,13 +60,12 @@ func Load() (*Config, error) {
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
- c.migrateLegacy()
+ c.normalize()
return &c, nil
}
func (c *Config) Save() error {
- c.migrateLegacy()
- c.mergeLegacyIntoProfile()
+ c.normalize()
dir := Dir()
if err := os.MkdirAll(dir, 0700); err != nil {
@@ -77,7 +78,12 @@ func (c *Config) Save() error {
return fmt.Errorf("encoding config: %w", err)
}
data = append(data, '\n')
- if err := os.WriteFile(Path(), data, 0600); err != nil {
+
+ // This file holds a bearer credential, so it is written all-or-nothing and
+ // its mode is enforced on every write — os.WriteFile's mode argument applies
+ // only when it creates the file, so a config that already existed as 0644
+ // would have kept those permissions forever.
+ if err := atomicfile.Write(Path(), data, 0600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
@@ -180,7 +186,7 @@ func (c *Config) ProfileNames() []string {
}
func (c *Config) EnsureProfile(name string) (*Profile, string, error) {
- c.migrateLegacy()
+ c.normalize()
target := strings.TrimSpace(name)
if target == "" {
@@ -215,6 +221,11 @@ func (c *Config) ResolveGroup(tag string) []string {
}
out := make([]string, 0)
for name, p := range c.Profiles {
+ // `"profiles":{"x":null}` unmarshals to a nil entry. Every other
+ // accessor guards against it; ranging p.Tags here panicked.
+ if p == nil {
+ continue
+ }
for _, t := range p.Tags {
if strings.ToLower(strings.TrimSpace(t)) == normalized {
out = append(out, name)
@@ -271,41 +282,77 @@ func getProfileOverride() string {
return strings.TrimSpace(profileOverride)
}
-func (c *Config) migrateLegacy() {
- if len(c.Profiles) > 0 {
- return
- }
- if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 {
+// normalize folds the legacy V1 top-level url/api_key/defaults into the
+// profiles map and then clears them.
+//
+// Consuming them exactly once is what makes later writes stick. Previously the
+// fields survived migration and Save() re-applied them over the active profile
+// on every write, so on any config upgraded from V1 `config set-key` and
+// `config set-url` silently reverted to the old value — the credential the user
+// just typed was discarded and the stale one written back.
+func (c *Config) normalize() {
+ if len(c.Profiles) == 0 {
+ if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 {
+ return
+ }
+ // Migrate into the profile active_profile actually names. Hardcoding
+ // "default" here while leaving ActiveProfile pointing elsewhere orphaned
+ // the credential: every command then failed with `profile "prod" not
+ // found` — including `config set-key`/`set-url`, which resolve through
+ // EnsureProfile, so the CLI could not repair its own config. The sibling
+ // branch below already creates the profile ActiveProfile names.
+ target := strings.TrimSpace(c.ActiveProfile)
+ if target == "" {
+ target = defaultProfileName
+ }
+ c.Profiles = map[string]*Profile{
+ target: {
+ URL: c.URL,
+ APIKey: c.APIKey,
+ Defaults: cloneDefaults(c.Defaults),
+ },
+ }
+ c.ActiveProfile = target
+ c.clearLegacy()
return
}
- c.Profiles = map[string]*Profile{
- defaultProfileName: {
- URL: c.URL,
- APIKey: c.APIKey,
- Defaults: cloneDefaults(c.Defaults),
- },
- }
- if strings.TrimSpace(c.ActiveProfile) == "" {
- c.ActiveProfile = defaultProfileName
- }
+ // Profiles already exist, so a hand-edited or partially-upgraded file may
+ // carry both shapes. Fold the legacy half into the active profile once.
+ c.mergeLegacyIntoProfile()
+ c.clearLegacy()
+}
+
+func (c *Config) clearLegacy() {
+ c.URL = ""
+ c.APIKey = ""
+ c.Defaults = nil
}
func (c *Config) mergeLegacyIntoProfile() {
if len(c.Profiles) == 0 {
return
}
+ if c.URL == "" && c.APIKey == "" && len(c.Defaults) == 0 {
+ return
+ }
targetName := strings.TrimSpace(c.ActiveProfile)
if targetName == "" {
targetName = defaultProfileName
}
- target, ok := c.Profiles[targetName]
- if !ok {
- target = c.Profiles[defaultProfileName]
- }
+ // Create the target when active_profile names a profile the map does not
+ // contain (a hand-edited file). Returning early instead would let normalize()
+ // clear the legacy fields with nothing to clear them into, silently dropping
+ // the only credential such a config has. Merging into an arbitrary other
+ // profile would be worse — it would move a credential somewhere unasked.
+ target := c.Profiles[targetName]
if target == nil {
- return
+ target = &Profile{}
+ c.Profiles[targetName] = target
+ }
+ if strings.TrimSpace(c.ActiveProfile) == "" {
+ c.ActiveProfile = targetName
}
if c.URL != "" {
@@ -319,29 +366,21 @@ func (c *Config) mergeLegacyIntoProfile() {
}
}
+// cloneForSave builds the on-disk payload. Callers reach it only through
+// Save(), which normalizes first, so the legacy V1 fields are always empty by
+// this point and are never written back — the file is V2-only going forward.
func (c *Config) cloneForSave() *Config {
out := &Config{
- URL: c.URL,
- APIKey: c.APIKey,
- Defaults: cloneDefaults(c.Defaults),
ActiveProfile: strings.TrimSpace(c.ActiveProfile),
Profiles: cloneProfiles(c.Profiles),
}
- if len(out.Profiles) > 0 {
- if out.ActiveProfile == "" {
- if _, ok := out.Profiles[defaultProfileName]; ok {
- out.ActiveProfile = defaultProfileName
- } else {
- names := profileNames(out.Profiles)
- if len(names) > 0 {
- out.ActiveProfile = names[0]
- }
- }
+ if len(out.Profiles) > 0 && out.ActiveProfile == "" {
+ if _, ok := out.Profiles[defaultProfileName]; ok {
+ out.ActiveProfile = defaultProfileName
+ } else if names := profileNames(out.Profiles); len(names) > 0 {
+ out.ActiveProfile = names[0]
}
- out.URL = ""
- out.APIKey = ""
- out.Defaults = nil
}
return out
@@ -349,7 +388,7 @@ func (c *Config) cloneForSave() *Config {
func (c *Config) ensureWritableProfile() (*Profile, string) {
if len(c.Profiles) == 0 {
- c.migrateLegacy()
+ c.normalize()
}
if len(c.Profiles) == 0 {
c.Profiles = map[string]*Profile{}
@@ -376,7 +415,7 @@ func (c *Config) ensureWritableProfile() (*Profile, string) {
}
func (c *Config) resolveProfile(name string) (*Profile, string, error) {
- c.migrateLegacy()
+ c.normalize()
target := strings.TrimSpace(name)
if target == "" {
@@ -387,13 +426,8 @@ func (c *Config) resolveProfile(name string) (*Profile, string, error) {
}
if len(c.Profiles) == 0 {
- if c.URL != "" || c.APIKey != "" || len(c.Defaults) > 0 {
- return &Profile{
- URL: c.URL,
- APIKey: c.APIKey,
- Defaults: cloneDefaults(c.Defaults),
- }, target, nil
- }
+ // normalize() has already folded any legacy fields into a profile, so an
+ // empty map here means a genuinely unconfigured CLI.
if target == defaultProfileName {
return &Profile{}, target, nil
}
diff --git a/go-cli/internal/config/config_test.go b/go-cli/internal/config/config_test.go
index 96d198cc..78164860 100644
--- a/go-cli/internal/config/config_test.go
+++ b/go-cli/internal/config/config_test.go
@@ -166,14 +166,22 @@ func TestSaveThenLoadRoundTrip(t *testing.T) {
tmp := t.TempDir()
setTestHome(t, tmp)
- original := &Config{
- URL: "https://tracker.example.com",
- APIKey: "roundtrip-key-abcd1234",
- }
+ const wantURL = "https://tracker.example.com"
+ const wantKey = "roundtrip-key-abcd1234"
+
+ original := &Config{URL: wantURL, APIKey: wantKey}
if err := original.Save(); err != nil {
t.Fatalf("Save() error: %v", err)
}
+ // Save() normalizes in place: the legacy fields are consumed into the
+ // profiles map and cleared, so a saved Config is left in canonical V2 form.
+ // That is what stops a later Save() from re-applying them over whatever the
+ // caller has since assigned to the profile.
+ if original.URL != "" || original.APIKey != "" {
+ t.Fatalf("legacy fields not consumed by Save(): url=%q api_key=%q", original.URL, original.APIKey)
+ }
+
loaded, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
@@ -182,11 +190,11 @@ func TestSaveThenLoadRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("ResolveProfile(default) error: %v", err)
}
- if profile.URL != original.URL {
- t.Fatalf("URL round-trip: got %q, want %q", profile.URL, original.URL)
+ if profile.URL != wantURL {
+ t.Fatalf("URL round-trip: got %q, want %q", profile.URL, wantURL)
}
- if profile.APIKey != original.APIKey {
- t.Fatalf("APIKey round-trip: got %q, want %q", profile.APIKey, original.APIKey)
+ if profile.APIKey != wantKey {
+ t.Fatalf("APIKey round-trip: got %q, want %q", profile.APIKey, wantKey)
}
}
diff --git a/go-cli/internal/config/legacy_upgrade_test.go b/go-cli/internal/config/legacy_upgrade_test.go
new file mode 100644
index 00000000..260ad698
--- /dev/null
+++ b/go-cli/internal/config/legacy_upgrade_test.go
@@ -0,0 +1,273 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeRawConfig(t *testing.T, home, contents string) {
+ t.Helper()
+ dir := filepath.Join(home, ".p202")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(contents), 0600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readRawConfig(t *testing.T) *Config {
+ t.Helper()
+ data, err := os.ReadFile(Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var c Config
+ if err := json.Unmarshal(data, &c); err != nil {
+ t.Fatalf("saved config is not valid JSON: %v", err)
+ }
+ return &c
+}
+
+// A V1 config file keeps its legacy top-level url/api_key after Load()
+// migrates them into a profile. Save() then re-merges those stale legacy
+// values over the profile, so the credential the user just set is discarded.
+// This is the exact `p202 config set-key` path.
+func TestSetKeyOnLegacyConfigPersistsNewKey(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{"url":"https://old.example.com","api_key":"old-key-12345678"}`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ p, name, err := cfg.EnsureProfile("")
+ if err != nil {
+ t.Fatalf("EnsureProfile: %v", err)
+ }
+ if name != defaultProfileName {
+ t.Fatalf("resolved profile = %q, want %q", name, defaultProfileName)
+ }
+ p.APIKey = "new-key-87654321"
+ if err := cfg.Save(); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ // The in-memory profile must still hold what the caller assigned; command
+ // code prints p.MaskedKey() after Save().
+ if p.APIKey != "new-key-87654321" {
+ t.Fatalf("in-memory API key = %q, want new-key-87654321", p.APIKey)
+ }
+
+ reloaded, err := Load()
+ if err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ got, _, err := reloaded.resolveProfile("")
+ if err != nil {
+ t.Fatalf("resolveProfile after reload: %v", err)
+ }
+ if got.APIKey != "new-key-87654321" {
+ t.Fatalf("persisted API key = %q, want new-key-87654321", got.APIKey)
+ }
+ if got.URL != "https://old.example.com" {
+ t.Fatalf("persisted URL = %q, want the migrated legacy URL", got.URL)
+ }
+}
+
+// Same defect via set-url.
+func TestSetURLOnLegacyConfigPersistsNewURL(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{"url":"https://old.example.com","api_key":"old-key-12345678"}`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ p, _, err := cfg.EnsureProfile("")
+ if err != nil {
+ t.Fatalf("EnsureProfile: %v", err)
+ }
+ p.URL = "https://new.example.com"
+ if err := cfg.Save(); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ reloaded, err := Load()
+ if err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ got, _, err := reloaded.resolveProfile("")
+ if err != nil {
+ t.Fatalf("resolveProfile after reload: %v", err)
+ }
+ if got.URL != "https://new.example.com" {
+ t.Fatalf("persisted URL = %q, want https://new.example.com", got.URL)
+ }
+}
+
+// A hand-edited or partially-upgraded file can carry BOTH legacy top-level
+// fields and a profiles map. The legacy values must be consumed once (merged
+// into the active profile at load) and then never re-applied, otherwise every
+// later write is silently reverted to them.
+func TestLegacyFieldsAreConsumedNotReappliedOnEverySave(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{
+ "url":"https://legacy.example.com",
+ "api_key":"legacy-key-1234",
+ "active_profile":"prod",
+ "profiles":{"prod":{"url":"https://prod.example.com","api_key":"prod-key-1234"}}
+ }`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ p, _, err := cfg.EnsureProfile("prod")
+ if err != nil {
+ t.Fatalf("EnsureProfile: %v", err)
+ }
+ p.APIKey = "chosen-key-1234"
+ if err := cfg.Save(); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ saved := readRawConfig(t)
+ if saved.URL != "" || saved.APIKey != "" {
+ t.Fatalf("legacy fields survived the save: url=%q api_key=%q", saved.URL, saved.APIKey)
+ }
+ prod := saved.Profiles["prod"]
+ if prod == nil {
+ t.Fatal("prod profile missing after save")
+ }
+ if prod.APIKey != "chosen-key-1234" {
+ t.Fatalf("persisted API key = %q, want chosen-key-1234", prod.APIKey)
+ }
+}
+
+// When active_profile names a profile that isn't in the map, the legacy
+// credential still has to land somewhere — clearing it with nowhere to go would
+// silently destroy the only credential the config has.
+func TestLegacyFieldsSurviveMissingActiveProfile(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{
+ "url":"https://legacy.example.com",
+ "api_key":"legacy-key-1234",
+ "active_profile":"prod",
+ "profiles":{"other":{"url":"https://other","api_key":"other-key-1234"}}
+ }`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if err := cfg.Save(); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ saved := readRawConfig(t)
+ prod := saved.Profiles["prod"]
+ if prod == nil {
+ t.Fatalf("legacy credential dropped: no prod profile in %+v", saved.Profiles)
+ }
+ if prod.APIKey != "legacy-key-1234" || prod.URL != "https://legacy.example.com" {
+ t.Fatalf("legacy values not preserved: url=%q api_key=%q", prod.URL, prod.APIKey)
+ }
+ // The unrelated profile must be left exactly as it was.
+ other := saved.Profiles["other"]
+ if other == nil || other.APIKey != "other-key-1234" {
+ t.Fatalf("unrelated profile was modified: %+v", other)
+ }
+}
+
+// A nil profile entry is representable in JSON (`"profiles":{"x":null}`) and
+// every other accessor guards against it. ResolveGroup must not panic.
+func TestResolveGroupToleratesNilProfileEntry(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{"active_profile":"a","profiles":{"a":{"url":"https://a","api_key":"k1234567","tags":["prod"]},"b":null}}`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ got := cfg.ResolveGroup("prod")
+ if len(got) != 1 || got[0] != "a" {
+ t.Fatalf("ResolveGroup(prod) = %v, want [a]", got)
+ }
+}
+
+// The config file holds a bearer credential. A file that already exists with
+// looser permissions (an older CLI wrote 0644, or an admin copied it in) must
+// be tightened on write — os.WriteFile's mode argument only applies when it
+// creates the file.
+func TestSaveTightensPermissionsOnPreexistingFile(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{"active_profile":"default","profiles":{"default":{"url":"https://a","api_key":"k1234567"}}}`)
+ if err := os.Chmod(Path(), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if err := cfg.Save(); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ info, err := os.Stat(Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := info.Mode().Perm(); mode != 0600 {
+ t.Fatalf("config mode = %04o, want 0600", mode)
+ }
+}
+
+// A V1 config whose active_profile names something other than "default" must
+// migrate the credential into THAT profile. Creating "default" while leaving
+// ActiveProfile pointing at the missing name orphaned the credential: every
+// command failed with `profile "prod" not found`, including config set-key and
+// set-url, so the CLI could not repair its own config. The sibling branch of
+// normalize() already handled this; this branch did not.
+func TestV1MigrationHonoursANonDefaultActiveProfile(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ writeRawConfig(t, home, `{"url":"https://prod.example.com","api_key":"prod-key-12345678","active_profile":"prod"}`)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if cfg.ActiveProfile != "prod" {
+ t.Fatalf("ActiveProfile = %q, want prod", cfg.ActiveProfile)
+ }
+ if _, ok := cfg.Profiles["prod"]; !ok {
+ t.Fatalf("credential was migrated into %v, not into the active profile", cfg.ProfileNames())
+ }
+
+ // The whole point: every resolution path must work, or the config is
+ // unusable and unrepairable from the CLI.
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("Validate: %v", err)
+ }
+ p, name, err := cfg.resolveProfile("")
+ if err != nil {
+ t.Fatalf("resolveProfile: %v", err)
+ }
+ if name != "prod" || p.APIKey != "prod-key-12345678" {
+ t.Fatalf("resolved %q with key %q", name, p.APIKey)
+ }
+ if _, _, err := cfg.EnsureProfile(""); err != nil {
+ t.Fatalf("EnsureProfile (the path config set-key uses): %v", err)
+ }
+}
diff --git a/go-cli/internal/forecast/forecast.go b/go-cli/internal/forecast/forecast.go
index 3b7d29e2..90ddab44 100644
--- a/go-cli/internal/forecast/forecast.go
+++ b/go-cli/internal/forecast/forecast.go
@@ -713,16 +713,29 @@ func ensembleWeights(eval *rollingEval, candidates []Method, include func(evalRo
if len(rmses) == 0 {
return nil
}
+ // Every comparison against NaN is false, so a non-finite RMSE would slip
+ // past both the `< best` and the `> dropFactor*best` guards below: it would
+ // neither set the baseline nor be pruned, and 1/(NaN+eps)^2 would then make
+ // that member's weight NaN and poison the whole mix. Skip such members
+ // explicitly — an unmeasurable error is not evidence of accuracy.
best := math.MaxFloat64
+ haveBest := false
for _, rmse := range rmses {
+ if !isFinite(rmse) {
+ continue
+ }
if rmse < best {
best = rmse
+ haveBest = true
}
}
+ if !haveBest {
+ return nil
+ }
weights := map[Method]float64{}
for _, m := range candidates {
rmse, ok := rmses[m]
- if !ok || rmse > ensembleDropFactor*best {
+ if !ok || !isFinite(rmse) || rmse > ensembleDropFactor*best {
continue
}
// Inverse-MSE (Bates–Granger) weighting on the recency-discounted
@@ -762,13 +775,23 @@ func nestedEnsemblePredictor(e *rollingEval, candidates []Method) rowPredictor {
}
}
+// isFinite reports whether f is a real number. Used at every point where a
+// value derived from a backtest feeds a comparison, because NaN compares false
+// against everything and therefore slips through range guards silently.
+func isFinite(f float64) bool {
+ return !math.IsNaN(f) && !math.IsInf(f, 0)
+}
+
// normalizeWeights scales the members' weights to sum to 1.
func normalizeWeights(weights map[Method]float64, members []Method) {
sum := 0.0
for _, m := range members {
sum += weights[m]
}
- if sum <= 0 {
+ // Written as !(sum > 0) rather than sum <= 0 so a NaN sum takes the equal-
+ // weights fallback too: NaN <= 0 is false, so the old form let it through
+ // and NaN/NaN made every member's weight NaN.
+ if !(sum > 0) || math.IsInf(sum, 0) {
for _, m := range members {
weights[m] = 1 / float64(len(members))
}
@@ -1075,7 +1098,20 @@ func applyProfile(preds []Prediction, profile func(time.Time) float64, logScale
continue
}
if v := math.Expm1(preds[i].Value); v > 0 {
- preds[i].Value = math.Log1p(v * w)
+ // Scale on the reporting scale, then return to the model scale.
+ // log1p is only defined above -1, and a profile weight can
+ // legitimately be negative — BuildWeekdayWeights divides a
+ // possibly-negative day value by a positive mean — so v*w can leave
+ // the domain. Log1p returns NaN there, and a single NaN propagates
+ // through the bounds, the quantiles and (via the backtest RMSE) the
+ // ensemble weights, turning every prediction into NaN. Clamp into
+ // the representable range instead; NonNegative configs then clip it
+ // to zero at output anyway.
+ scaled := v * w
+ if scaled <= -1 {
+ scaled = math.Nextafter(-1, 0)
+ }
+ preds[i].Value = math.Log1p(scaled)
}
}
}
diff --git a/go-cli/internal/forecast/nan_containment_test.go b/go-cli/internal/forecast/nan_containment_test.go
new file mode 100644
index 00000000..df99ffcd
--- /dev/null
+++ b/go-cli/internal/forecast/nan_containment_test.go
@@ -0,0 +1,99 @@
+package forecast
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+// buildFlatSeries returns n daily points around base, with a small deterministic
+// wobble so the models have something to fit.
+func buildFlatSeries(n int, base float64) Series {
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ s := make(Series, 0, n)
+ for i := 0; i < n; i++ {
+ s = append(s, Point{
+ T: start.AddDate(0, 0, i),
+ V: base + float64(i%5),
+ })
+ }
+ return s
+}
+
+func assertAllFinite(t *testing.T, preds []Prediction) {
+ t.Helper()
+ for i, p := range preds {
+ if !isFinite(p.Value) || !isFinite(p.LowerBound) || !isFinite(p.UpperBound) {
+ t.Fatalf("prediction %d is not finite: value=%v lower=%v upper=%v",
+ i, p.Value, p.LowerBound, p.UpperBound)
+ }
+ for q, v := range p.Quantiles {
+ if !isFinite(v) {
+ t.Fatalf("prediction %d quantile %v is not finite: %v", i, q, v)
+ }
+ }
+ }
+}
+
+// A negative seasonal multiplier drives v*w below -1, which is outside log1p's
+// domain. Log1p returned NaN there, and the NaN propagated through the bounds,
+// the quantiles and the ensemble weights until every prediction was NaN — which
+// serializes straight into the JSON and CSV output. BuildWeekdayWeights is a
+// legitimate producer of negative weights (it divides a possibly-negative day
+// value by a positive mean), so this is reachable through the exported API.
+func TestNegativeSeasonalWeightUnderLogTransformStaysFinite(t *testing.T) {
+ weights := SeasonalWeights{}
+ for d := time.Sunday; d <= time.Saturday; d++ {
+ weights[d] = 1.1
+ }
+ weights[time.Monday] = -0.6
+
+ res, err := Run(buildFlatSeries(60, 100), Config{
+ Horizon: 7,
+ Interval: IntervalDay,
+ NonNegative: true,
+ LogTransform: true,
+ SeasonalWeights: weights,
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(res.Predictions) != 7 {
+ t.Fatalf("got %d predictions, want 7", len(res.Predictions))
+ }
+ assertAllFinite(t, res.Predictions)
+}
+
+// applyProfile is the unit that used to produce the NaN. Every weight, including
+// one steep enough to leave log1p's domain, must yield a finite model-scale
+// value.
+func TestApplyProfileStaysInLog1pDomain(t *testing.T) {
+ for _, w := range []float64{-1000, -2, -1, -0.6, 0, 0.5, 2, 1000} {
+ preds := []Prediction{{T: time.Now(), Value: math.Log1p(100)}}
+ applyProfile(preds, func(time.Time) float64 { return w }, true)
+ if !isFinite(preds[0].Value) {
+ t.Fatalf("weight %v produced a non-finite model value: %v", w, preds[0].Value)
+ }
+ }
+}
+
+// NaN compares false against everything, so a single unmeasurable member used to
+// pass both the best-RMSE and the pruning guard, and 1/(NaN+eps)^2 then poisoned
+// every other member through normalizeWeights.
+func TestNormalizeWeightsFallsBackWhenTheSumIsNotFinite(t *testing.T) {
+ members := []Method{MethodLinear, MethodSMA}
+
+ for name, poisoned := range map[string]float64{
+ "NaN": math.NaN(),
+ "+Inf": math.Inf(1),
+ "-Inf": math.Inf(-1),
+ } {
+ weights := map[Method]float64{MethodLinear: poisoned, MethodSMA: 1}
+ normalizeWeights(weights, members)
+ for _, m := range members {
+ if !isFinite(weights[m]) {
+ t.Fatalf("%s: member %s weight is not finite: %v", name, m, weights[m])
+ }
+ }
+ }
+}
diff --git a/go-cli/internal/metrics/metrics.go b/go-cli/internal/metrics/metrics.go
index 9c7b65b4..1cdbc8fb 100644
--- a/go-cli/internal/metrics/metrics.go
+++ b/go-cli/internal/metrics/metrics.go
@@ -24,11 +24,15 @@ func Enabled() bool {
}
// Event represents a single telemetry event.
+//
+// duration_ms carries no omitempty on purpose: an operation that finishes inside
+// a millisecond has a genuine duration of 0, and dropping the field left log
+// consumers unable to tell "completed instantly" from "never measured".
type Event struct {
Op string `json:"op"`
Entity string `json:"entity,omitempty"`
Action string `json:"action,omitempty"`
- Duration float64 `json:"duration_ms,omitempty"`
+ Duration float64 `json:"duration_ms"`
Count int `json:"count,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
@@ -68,10 +72,16 @@ func Timer(op, entity string) func(success bool, errMsg string) {
}
}
+// appendTimestamp returns a copy of fields carrying the emission timestamp.
+// It must not write into the caller's map: Emit takes Event by value but the
+// Fields map is shared with the caller, so stamping it in place mutated data the
+// caller still owns — and would be an unsynchronized map write if that caller
+// built the event on one of the worker goroutines.
func appendTimestamp(fields map[string]string) map[string]string {
- if fields == nil {
- fields = map[string]string{}
+ out := make(map[string]string, len(fields)+1)
+ for k, v := range fields {
+ out[k] = v
}
- fields["ts"] = time.Now().UTC().Format(time.RFC3339)
- return fields
+ out["ts"] = time.Now().UTC().Format(time.RFC3339)
+ return out
}
diff --git a/go-cli/internal/output/output.go b/go-cli/internal/output/output.go
index 47560d96..353fbfb5 100644
--- a/go-cli/internal/output/output.go
+++ b/go-cli/internal/output/output.go
@@ -543,7 +543,7 @@ func renderTableCSV(items []interface{}, opts Opts) {
}
record := make([]string, len(keys))
for i, k := range keys {
- record[i] = formatValue(obj[k])
+ record[i] = formatValueExact(obj[k])
}
if err := writer.Write(record); err != nil {
fmt.Fprintln(os.Stderr, "Error writing CSV row:", err)
@@ -569,7 +569,7 @@ func renderObjectCSV(obj map[string]interface{}) {
return
}
for _, k := range keys {
- if err := writer.Write([]string{k, formatValue(obj[k])}); err != nil {
+ if err := writer.Write([]string{k, formatValueExact(obj[k])}); err != nil {
fmt.Fprintln(os.Stderr, "Error writing CSV row:", err)
return
}
@@ -580,7 +580,21 @@ func renderObjectCSV(obj map[string]interface{}) {
}
}
+// formatValue renders a value for human display, rounding floats to 2 decimals.
func formatValue(v interface{}) string {
+ return formatScalar(v, false)
+}
+
+// formatValueExact renders a value without lossy rounding, for machine-facing
+// output. The API sends computed metrics (roi, epc, margin) as JSON numbers
+// rather than strings, so rounding them here truncated exported data: a --csv
+// export of 0.288613861 came out as 0.29, and the caller had no way to tell it
+// had lost precision.
+func formatValueExact(v interface{}) string {
+ return formatScalar(v, true)
+}
+
+func formatScalar(v interface{}, exact bool) string {
if v == nil {
return ""
}
@@ -589,7 +603,10 @@ func formatValue(v interface{}) string {
return val
case float64:
if val == float64(int64(val)) {
- return fmt.Sprintf("%d", int64(val))
+ return strconv.FormatInt(int64(val), 10)
+ }
+ if exact {
+ return strconv.FormatFloat(val, 'f', -1, 64)
}
return fmt.Sprintf("%.2f", val)
case bool:
diff --git a/go-cli/internal/shell/state.go b/go-cli/internal/shell/state.go
index 52409e67..e8f289e6 100644
--- a/go-cli/internal/shell/state.go
+++ b/go-cli/internal/shell/state.go
@@ -70,12 +70,12 @@ func (s *State) FormatVarsList() string {
}
var b strings.Builder
for _, name := range s.Names() {
- raw := s.vars[name]
- preview := string(raw)
- if len(preview) > 80 {
- preview = preview[:77] + "..."
+ preview := strings.ReplaceAll(string(s.vars[name]), "\n", " ")
+ // Truncate by runes, not bytes: API payloads carry non-ASCII names, and a
+ // byte cut through a multi-byte rune emits a replacement character.
+ if r := []rune(preview); len(r) > 80 {
+ preview = string(r[:77]) + "..."
}
- preview = strings.ReplaceAll(preview, "\n", " ")
fmt.Fprintf(&b, "$%s = %s\n", name, preview)
}
return b.String()
diff --git a/go-cli/internal/syncstate/lock_unix.go b/go-cli/internal/syncstate/lock_unix.go
new file mode 100644
index 00000000..f1ac9f96
--- /dev/null
+++ b/go-cli/internal/syncstate/lock_unix.go
@@ -0,0 +1,36 @@
+//go:build !windows
+
+package syncstate
+
+import (
+ "errors"
+ "os"
+ "syscall"
+)
+
+// acquireLockFile takes an exclusive, non-blocking advisory lock on path via
+// flock(2). The lock belongs to the open file description, so the kernel
+// releases it when the process exits by any means — that is what makes a
+// leftover lock file harmless rather than a permanent block.
+func acquireLockFile(path string) (*os.File, error) {
+ file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, err
+ }
+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ _ = file.Close()
+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
+ return nil, ErrLockHeld
+ }
+ return nil, err
+ }
+ return file, nil
+}
+
+// releaseLockFile drops the lock. Closing the descriptor releases the flock on
+// its own; the explicit LOCK_UN keeps the intent obvious. The file itself is
+// left in place on purpose — see the AcquireLock doc comment.
+func releaseLockFile(file *os.File) {
+ _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+ _ = file.Close()
+}
diff --git a/go-cli/internal/syncstate/lock_windows.go b/go-cli/internal/syncstate/lock_windows.go
new file mode 100644
index 00000000..8e6f7b35
--- /dev/null
+++ b/go-cli/internal/syncstate/lock_windows.go
@@ -0,0 +1,63 @@
+//go:build windows
+
+package syncstate
+
+import (
+ "os"
+
+ "golang.org/x/sys/windows"
+)
+
+// acquireLockFile takes an exclusive, non-blocking lock on path via LockFileEx,
+// the Windows counterpart to flock(2). Windows releases the lock when the
+// handle closes, including on abnormal termination, so a leftover lock file does
+// not block later runs.
+func acquireLockFile(path string) (*os.File, error) {
+ file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, err
+ }
+ overlapped := lockRegion()
+ err = windows.LockFileEx(
+ windows.Handle(file.Fd()),
+ windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
+ 0,
+ 1,
+ 0,
+ &overlapped,
+ )
+ if err != nil {
+ _ = file.Close()
+ if err == windows.ERROR_LOCK_VIOLATION || err == windows.ERROR_IO_PENDING {
+ return nil, ErrLockHeld
+ }
+ return nil, err
+ }
+ return file, nil
+}
+
+// releaseLockFile drops the lock. The file itself is left in place on purpose —
+// see the AcquireLock doc comment.
+func releaseLockFile(file *os.File) {
+ overlapped := lockRegion()
+ _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped)
+ _ = file.Close()
+}
+
+// lockRegion is the byte range LockFileEx locks: one byte at a very high
+// offset, past any content the file will ever hold.
+//
+// It deliberately does NOT cover byte 0. Windows byte-range locks are
+// mandatory, not advisory, so locking the start of the file made the
+// "pid=... time=..." line unreadable to a contending process: readLockHolder's
+// os.ReadFile failed with ERROR_LOCK_VIOLATION and the contention message fell
+// back to the generic form, never naming the holder — while the unix test
+// asserts it does. Locking past the data keeps the diagnostic readable and the
+// mutual exclusion identical, since every participant locks the same range.
+// Locking a range beyond end-of-file is legal on Windows.
+func lockRegion() windows.Overlapped {
+ return windows.Overlapped{
+ Offset: 0,
+ OffsetHigh: 0x8000_0000,
+ }
+}
diff --git a/go-cli/internal/syncstate/state.go b/go-cli/internal/syncstate/state.go
index 8df2e0f6..18fe928f 100644
--- a/go-cli/internal/syncstate/state.go
+++ b/go-cli/internal/syncstate/state.go
@@ -2,12 +2,16 @@ package syncstate
import (
"encoding/json"
+ "errors"
"fmt"
+ "io"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
+ "p202/internal/atomicfile"
configpkg "p202/internal/config"
syncdata "p202/internal/sync"
)
@@ -93,44 +97,113 @@ func SaveManifestAtomic(manifest *Manifest) error {
return fmt.Errorf("creating sync state dir: %w", err)
}
- path := ManifestPath(manifest.Source, manifest.Target)
- tmpPath := path + ".tmp"
-
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
data = append(data, '\n')
- if err := os.WriteFile(tmpPath, data, 0600); err != nil {
- return fmt.Errorf("writing temp manifest: %w", err)
- }
- if err := os.Rename(tmpPath, path); err != nil {
- return fmt.Errorf("renaming manifest: %w", err)
+ // The manifest decides what the next incremental sync skips, so a truncated
+ // one would make the CLI silently re-create or skip records. The previous
+ // write-then-rename left its temp file behind whenever the rename failed and
+ // never flushed before renaming.
+ if err := atomicfile.Write(ManifestPath(manifest.Source, manifest.Target), data, 0600); err != nil {
+ return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
+// ErrLockHeld reports that another live process holds the sync lock.
+var ErrLockHeld = errors.New("sync lock is already held")
+
+// AcquireLock takes the exclusive sync lock for a profile pair and returns the
+// release function.
+//
+// The lock is a kernel-held file lock (flock on unix, LockFileEx on Windows),
+// not the mere existence of the lock file. That distinction is the fix for a
+// permanent wedge: the previous implementation used O_CREATE|O_EXCL, so a sync
+// killed mid-run (SIGKILL, crash, power loss) left the file behind and every
+// later sync for that pair failed forever — and the pid it recorded, the one
+// piece of data that could have diagnosed it, was never read by anything. The
+// OS drops a kernel lock when the holding process dies, so a leftover lock file
+// is now harmless. It is deliberately not unlinked on release: unlinking a
+// flock'd path lets a waiter hold a lock on an already-unlinked inode while a
+// third process locks the freshly created one, and both would think they won.
func AcquireLock(source, target string) (func(), error) {
if err := os.MkdirAll(Dir(), 0700); err != nil {
return nil, fmt.Errorf("creating sync state dir: %w", err)
}
path := LockPath(source, target)
- file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+
+ file, err := acquireLockFile(path)
if err != nil {
- if os.IsExist(err) {
- return nil, fmt.Errorf("sync lock is already held for %s -> %s", source, target)
+ if errors.Is(err, ErrLockHeld) {
+ holder := readLockHolder(path)
+ if holder.pid > 0 {
+ return nil, fmt.Errorf("%w for %s -> %s by pid %d (since %s); wait for it to finish",
+ ErrLockHeld, source, target, holder.pid, holder.since)
+ }
+ return nil, fmt.Errorf("%w for %s -> %s", ErrLockHeld, source, target)
}
return nil, fmt.Errorf("creating lock file: %w", err)
}
- _, _ = file.WriteString(fmt.Sprintf("pid=%d time=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)))
+ // Record the holder for the contention message above. Truncate first: the
+ // file survives across runs, so a shorter pid line must not leave a previous
+ // holder's trailing bytes behind.
+ if err := writeLockHolder(file); err != nil {
+ releaseLockFile(file)
+ return nil, fmt.Errorf("writing lock file: %w", err)
+ }
+
+ return func() { releaseLockFile(file) }, nil
+}
- release := func() {
- _ = file.Close()
- _ = os.Remove(path)
+func writeLockHolder(file *os.File) error {
+ if err := file.Truncate(0); err != nil {
+ return err
+ }
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ line := fmt.Sprintf("pid=%d time=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))
+ if _, err := file.WriteString(line); err != nil {
+ return err
+ }
+ return file.Sync()
+}
+
+type lockHolder struct {
+ pid int
+ since string
+}
+
+// readLockHolder parses the "pid=N time=T" line written by AcquireLock. It is
+// purely informational — the kernel lock, not this content, decides ownership —
+// so an unreadable or malformed file just yields an unidentified holder.
+func readLockHolder(path string) lockHolder {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return lockHolder{}
+ }
+ holder := lockHolder{since: "unknown"}
+ for _, field := range strings.Fields(strings.TrimSpace(string(data))) {
+ key, value, ok := strings.Cut(field, "=")
+ if !ok {
+ continue
+ }
+ switch key {
+ case "pid":
+ if pid, err := strconv.Atoi(value); err == nil && pid > 0 {
+ holder.pid = pid
+ }
+ case "time":
+ if value != "" {
+ holder.since = value
+ }
+ }
}
- return release, nil
+ return holder
}
func (m *Manifest) SetMapping(entity, sourceID, targetID, sourceName, sourceHash string, at time.Time) {
diff --git a/go-cli/internal/syncstate/state_test.go b/go-cli/internal/syncstate/state_test.go
index 6cb59c2e..88f09580 100644
--- a/go-cli/internal/syncstate/state_test.go
+++ b/go-cli/internal/syncstate/state_test.go
@@ -2,6 +2,8 @@ package syncstate
import (
"encoding/json"
+ "errors"
+ "fmt"
"os"
"path/filepath"
"runtime"
@@ -319,9 +321,75 @@ func TestAcquireLockSucceedsFirst(t *testing.T) {
release()
- // Lock file should be removed after release.
- if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
- t.Fatalf("lock file should be removed after release, stat err = %v", err)
+ // The lock file is intentionally left on disk: ownership is the kernel lock,
+ // not the file's existence, and unlinking a flock'd path lets two processes
+ // believe they hold it. What must hold after release is that the lock can be
+ // taken again.
+ if _, err := os.Stat(lockPath); err != nil {
+ t.Fatalf("lock file should persist after release: %v", err)
+ }
+ again, err := AcquireLock("src", "dst")
+ if err != nil {
+ t.Fatalf("lock should be re-acquirable after release: %v", err)
+ }
+ again()
+}
+
+// A lock file left behind by a process that died mid-sync used to block every
+// later sync for that pair forever. The kernel drops the lock when the holder
+// dies, so a leftover file must not block anything.
+func TestAcquireLockIgnoresLeftoverFileFromDeadProcess(t *testing.T) {
+ tmp := t.TempDir()
+ setTestHome(t, tmp)
+
+ if err := os.MkdirAll(Dir(), 0700); err != nil {
+ t.Fatal(err)
+ }
+ lockPath := LockPath("src", "dst")
+ // pid 0x7FFFFFFF is not a live process; the content is informational only.
+ if err := os.WriteFile(lockPath, []byte("pid=2147483647 time=2020-01-01T00:00:00Z\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ release, err := AcquireLock("src", "dst")
+ if err != nil {
+ t.Fatalf("stale lock file should not block acquisition: %v", err)
+ }
+ defer release()
+
+ // The stale holder metadata must be replaced, not appended to.
+ data, err := os.ReadFile(lockPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(data), "2147483647") {
+ t.Fatalf("stale holder metadata survived acquisition: %q", data)
+ }
+ if !strings.Contains(string(data), fmt.Sprintf("pid=%d", os.Getpid())) {
+ t.Fatalf("lock file should record the current pid, got %q", data)
+ }
+}
+
+// The contention error must name the holder so a user can act on it.
+func TestAcquireLockContentionReportsHolder(t *testing.T) {
+ tmp := t.TempDir()
+ setTestHome(t, tmp)
+
+ release, err := AcquireLock("src", "dst")
+ if err != nil {
+ t.Fatalf("first AcquireLock() error: %v", err)
+ }
+ defer release()
+
+ _, err = AcquireLock("src", "dst")
+ if err == nil {
+ t.Fatal("second AcquireLock() should fail")
+ }
+ if !errors.Is(err, ErrLockHeld) {
+ t.Fatalf("error should wrap ErrLockHeld, got %v", err)
+ }
+ if !strings.Contains(err.Error(), fmt.Sprintf("pid %d", os.Getpid())) {
+ t.Fatalf("contention error should name the holding pid, got %v", err)
}
}
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 0a2bb1a4..b38f9472 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -63,6 +63,7 @@ parameters:
rules:
# CLAUDE.md #1 — unchecked return values after fallible calls.
- Prosper202\PHPStan\Rules\ForbidDirectMysqliStmtCallRule
+ - Prosper202\PHPStan\Rules\UncheckedTransactionBoundaryRule
# CLAUDE.md #4 — silent data loss on malformed input.
- Prosper202\PHPStan\Rules\ForbidSilentJsonDecodeRule
# CLAUDE.md #5 — inconsistent security patterns across similar operations.
diff --git a/start.sh b/start.sh
index 097e192a..b00a7bbb 100755
--- a/start.sh
+++ b/start.sh
@@ -33,10 +33,15 @@ gen_secret() {
# into the database volume on first start, so we never overwrite an existing one.
if [ ! -f .env ]; then
echo "Creating .env with a generated database password..."
+ # Create it 0600 BEFORE writing: this file holds the MySQL root password and
+ # the default umask would otherwise leave it world-readable (0644). install.sh
+ # already does this for the .env it writes; both paths must match.
+ (umask 077; : > .env)
{
echo "MYSQL_ROOT_PASSWORD=$(gen_secret)"
echo "APP_ENV=development"
} > .env
+ chmod 600 .env 2>/dev/null || true
fi
docker compose up -d --build
diff --git a/tests/Api/DeletedUserApiAccessIntegrationTest.php b/tests/Api/DeletedUserApiAccessIntegrationTest.php
new file mode 100644
index 00000000..7b2340bf
--- /dev/null
+++ b/tests/Api/DeletedUserApiAccessIntegrationTest.php
@@ -0,0 +1,155 @@
+query($sql); }');
+ }
+ mysqli_report(MYSQLI_REPORT_STRICT);
+ // STRICT reporting makes a failed connect THROW, so catch it and leave
+ // self::$db null — the tests then skip instead of erroring the suite.
+ try {
+ $db = @mysqli_connect(
+ $host,
+ (string) (getenv('P202_TEST_DB_USER') ?: 'root'),
+ (string) (getenv('P202_TEST_DB_PASS') ?: ''),
+ (string) (getenv('P202_TEST_DB_NAME') ?: 'prosper202'),
+ (int) (getenv('P202_TEST_DB_PORT') ?: 3306)
+ );
+ } catch (\Throwable) {
+ return;
+ }
+ if (!$db) {
+ return;
+ }
+ $db->query("SET SESSION sql_mode=''");
+ (new SchemaInstaller($db))->install();
+ self::$db = $db;
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ if (self::$db) {
+ self::$db->close();
+ self::$db = null;
+ }
+ }
+
+ protected function setUp(): void
+ {
+ if (self::$db === null) {
+ self::markTestSkipped('No test database configured (P202_TEST_DB_HOST).');
+ }
+ self::$db->query('TRUNCATE TABLE 202_api_keys');
+ self::$db->query('DELETE FROM 202_users WHERE user_id IN (4001, 4002)');
+ }
+
+ private function seedUser(int $userId, string $apiKey, int $deleted): void
+ {
+ self::$db->query(
+ "INSERT INTO 202_users SET user_id={$userId}, user_name='u{$userId}', user_pass='x', " .
+ "user_email='u{$userId}@example.com', user_deleted={$deleted}, user_dash_email='', " .
+ "install_hash='', user_hash='', user_time_register=1"
+ );
+ self::$db->query(
+ "INSERT INTO 202_api_keys SET user_id={$userId}, api_key='" .
+ self::$db->real_escape_string($apiKey) . "', created_at=1"
+ );
+ }
+
+ /** The exact auth SQL shape each API version issues. */
+ private function authRowCount(string $sql, string $apiKey): int
+ {
+ $stmt = self::$db->prepare($sql);
+ self::assertNotFalse($stmt, 'auth query failed to prepare: ' . self::$db->error);
+ $stmt->bind_param('s', $apiKey);
+ self::assertTrue($stmt->execute());
+ $rows = $stmt->get_result()->num_rows;
+ $stmt->close();
+ return $rows;
+ }
+
+ /** @return array version => auth SQL */
+ private function authQueries(): array
+ {
+ return [
+ 'v1' => 'SELECT k.* FROM `202_api_keys` k
+ INNER JOIN `202_users` u ON u.`user_id` = k.`user_id`
+ WHERE k.`api_key` = ? AND u.`user_deleted` = 0',
+ 'v2' => 'SELECT k.* FROM `202_api_keys` k
+ INNER JOIN `202_users` u ON u.`user_id` = k.`user_id`
+ WHERE k.`api_key` = ? AND u.`user_deleted` = 0',
+ 'v2_attribution' => 'SELECT k.user_id FROM 202_api_keys k
+ INNER JOIN 202_users u ON u.user_id = k.user_id
+ WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1',
+ 'v3' => 'SELECT k.user_id FROM 202_api_keys k
+ INNER JOIN 202_users u ON u.user_id = k.user_id
+ WHERE k.api_key = ? AND u.user_deleted = 0 LIMIT 1',
+ ];
+ }
+
+ public function testActiveUserKeyAuthenticatesOnEveryApiVersion(): void
+ {
+ $this->seedUser(4001, 'live-key', 0);
+
+ foreach ($this->authQueries() as $version => $sql) {
+ self::assertSame(1, $this->authRowCount($sql, 'live-key'), "{$version} must accept an active user's key");
+ }
+ }
+
+ public function testSoftDeletedUserKeyIsRejectedOnEveryApiVersion(): void
+ {
+ $this->seedUser(4002, 'dead-key', 1);
+
+ foreach ($this->authQueries() as $version => $sql) {
+ self::assertSame(0, $this->authRowCount($sql, 'dead-key'), "{$version} must reject a deleted user's key");
+ }
+ }
+
+ public function testUiDeleteRevokesKeysSoNoVersionCanAuthenticate(): void
+ {
+ $this->seedUser(4001, 'ui-key', 0);
+
+ // What 202-account/user-management.php now runs on delete.
+ self::$db->query('UPDATE 202_users SET user_deleted = 1 WHERE user_id = 4001');
+ self::$db->query('DELETE FROM 202_api_keys WHERE user_id = 4001');
+
+ self::assertSame(0, (int) self::$db->query(
+ "SELECT COUNT(*) AS c FROM 202_api_keys WHERE api_key = 'ui-key'"
+ )->fetch_assoc()['c'], 'UI delete must drop the key rows');
+
+ foreach ($this->authQueries() as $version => $sql) {
+ self::assertSame(0, $this->authRowCount($sql, 'ui-key'), "{$version} must reject a UI-deleted user's key");
+ }
+ }
+}
diff --git a/tests/Api/V3/ApiKeyAuthPathScopeTest.php b/tests/Api/V3/ApiKeyAuthPathScopeTest.php
index 0517d4e5..7aed0b31 100644
--- a/tests/Api/V3/ApiKeyAuthPathScopeTest.php
+++ b/tests/Api/V3/ApiKeyAuthPathScopeTest.php
@@ -4,6 +4,7 @@
namespace Tests\Api\V3;
+use Tests\Support\SourceScan;
use Tests\TestCase;
/**
@@ -45,24 +46,10 @@ final class ApiKeyAuthPathScopeTest extends TestCase
*/
private function authenticatingFiles(): array
{
- $root = dirname(__DIR__, 3);
$found = [];
-
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveCallbackFilterIterator(
- new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
- static fn(\SplFileInfo $f): bool =>
- !in_array($f->getFilename(), ['vendor', 'node_modules', '.git', 'tests'], true)
- )
- );
-
- foreach ($iterator as $file) {
- if (!$file->isFile() || $file->getExtension() !== 'php') {
- continue;
- }
- $source = (string)file_get_contents($file->getPathname());
+ foreach (SourceScan::phpFiles() as $path => $source) {
if ($this->hasAuthenticatingSelect($source)) {
- $found[str_replace($root . '/', '', $file->getPathname())] = $source;
+ $found[$path] = $source;
}
}
diff --git a/tests/Api/V3/AttributionControllerWebhookGuardTest.php b/tests/Api/V3/AttributionControllerWebhookGuardTest.php
new file mode 100644
index 00000000..0e98c1e2
--- /dev/null
+++ b/tests/Api/V3/AttributionControllerWebhookGuardTest.php
@@ -0,0 +1,111 @@
+db = new FakeMysqliConnection();
+ $this->db->whenQueryContainsReturnRows('FROM 202_attribution_models', [
+ ['model_id' => 7, 'user_id' => 1, 'model_name' => 'm', 'model_type' => 'linear'],
+ ]);
+ $this->db->whenQueryContainsInsertId('INSERT INTO 202_attribution_exports', 42);
+
+ return new AttributionController($this->db, 1);
+ }
+
+ /**
+ * @dataProvider unsafeUrls
+ */
+ public function testAnUnsafeWebhookUrlIsRejectedBeforeTheInsert(string $url): void
+ {
+ $controller = $this->controller();
+
+ try {
+ $controller->scheduleExport(7, ['webhook_url' => $url]);
+ self::fail('expected a ValidationException');
+ } catch (ValidationException $e) {
+ self::assertArrayHasKey('webhook_url', $e->getFieldErrors());
+ }
+
+ self::assertCount(1, $this->db->preparedSql, 'only getModel() may have run; nothing was inserted');
+ self::assertStringStartsWith('SELECT', $this->db->preparedSql[0]);
+ }
+
+ /** @return array */
+ public static function unsafeUrls(): array
+ {
+ return [
+ 'cleartext' => ['http://203.0.113.10/hook'],
+ 'loopback' => ['https://127.0.0.1/hook'],
+ 'metadata' => ['https://169.254.169.254/hook'],
+ 'private' => ['https://10.0.0.5/hook'],
+ 'bad port' => ['https://203.0.113.10:9000/hook'],
+ 'garbage' => ['not a url'],
+ ];
+ }
+
+ public function testAHostnameIsAcceptedWithoutBeingResolved(): void
+ {
+ // .invalid can never resolve (RFC 6761). If the write boundary did DNS,
+ // this would be rejected as "does not resolve" -- or hang on a slow
+ // resolver -- for a URL the cron is perfectly able to check later.
+ $controller = $this->controller();
+ $this->scheduleExpectingTheInsertToLand($controller, ['webhook_url' => 'https://hooks.example.invalid/export']);
+
+ $insert = $this->db->statementsContaining('INSERT INTO 202_attribution_exports');
+ self::assertCount(1, $insert);
+ self::assertSame(1, $insert[0]->executeCount);
+ self::assertSame('https://hooks.example.invalid/export', $insert[0]->boundValues[11]);
+ }
+
+ /**
+ * Runs scheduleExport() up to and including the INSERT. The controller then
+ * reads the native mysqli_stmt::$insert_id, which a constructor-skipping
+ * fake cannot provide (PHP throws "object is already closed"); as in
+ * ControllerTest, reaching that Error is the proof that validation passed
+ * and the write executed -- a rejection would have thrown a
+ * ValidationException before any INSERT was prepared.
+ *
+ * @param array $payload
+ */
+ private function scheduleExpectingTheInsertToLand(AttributionController $controller, array $payload): void
+ {
+ try {
+ $controller->scheduleExport(7, $payload);
+ } catch (\Error $e) {
+ self::assertStringContainsString('already closed', $e->getMessage());
+ }
+ }
+
+ public function testNoWebhookUrlSkipsTheGuardEntirely(): void
+ {
+ $controller = $this->controller();
+ $this->scheduleExpectingTheInsertToLand($controller, []);
+
+ $insert = $this->db->statementsContaining('INSERT INTO 202_attribution_exports');
+ self::assertCount(1, $insert);
+ self::assertSame(1, $insert[0]->executeCount);
+ self::assertSame('', $insert[0]->boundValues[11]);
+ }
+}
diff --git a/tests/Api/V3/DoubleStatementCloseTest.php b/tests/Api/V3/DoubleStatementCloseTest.php
new file mode 100644
index 00000000..31330686
--- /dev/null
+++ b/tests/Api/V3/DoubleStatementCloseTest.php
@@ -0,0 +1,66 @@
+close()` after one of them is a second close, and on PHP 8 that
+ * throws "mysqli_stmt object is already closed" -- from inside whatever
+ * transaction the code sits in, which then rolls back. The rotator repository
+ * shipped exactly this, twice, in code that read as a tidy cleanup.
+ *
+ * The scan is token-based: it follows the variable handed to the helper
+ * through the rest of the enclosing function and reports a close() on it
+ * unless the variable was reassigned first. Shapes covered are pinned in
+ * SourceScanTest.
+ */
+final class DoubleStatementCloseTest extends TestCase
+{
+ /** Connection methods that close the statement themselves. */
+ private const CLOSING_HELPERS = ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate'];
+
+ public function testTheHelperListMatchesConnection(): void
+ {
+ // If Connection gains another closing helper, or one stops closing,
+ // this list must follow -- otherwise the scan below is quietly wrong.
+ $source = SourceScan::phpFiles()['202-config/Database/Connection.php'];
+ foreach (self::CLOSING_HELPERS as $helper) {
+ self::assertSame(
+ 1,
+ preg_match('/public function ' . $helper . '\(object \$stmt\).*?\$stmt->close\(\);/s', $source),
+ "Connection::$helper() must close the statement it is given, or be removed from CLOSING_HELPERS"
+ );
+ }
+ }
+
+ public function testNoStatementIsClosedTwice(): void
+ {
+ $found = [];
+ foreach (SourceScan::phpFiles() as $path => $source) {
+ if (!str_contains($source, '->close(')) {
+ continue;
+ }
+ $lines = SourceScan::closesAfterClosingHelper($source, self::CLOSING_HELPERS);
+ if ($lines !== []) {
+ $found[$path] = $lines;
+ }
+ }
+
+ self::assertSame([], $found, sprintf(
+ "These close a statement that fetchOne()/fetchAll()/executeInsert()/executeUpdate() already closed:\n%s\n"
+ . 'On PHP 8 the second close throws "mysqli_stmt object is already closed", aborting whatever '
+ . 'transaction it sits in. Drop the redundant close().',
+ implode("\n", array_map(
+ static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines),
+ array_keys($found),
+ $found
+ ))
+ ));
+ }
+}
diff --git a/tests/Api/V3/DuplicateGlobalClassTest.php b/tests/Api/V3/DuplicateGlobalClassTest.php
index 2e835814..6ec254f3 100644
--- a/tests/Api/V3/DuplicateGlobalClassTest.php
+++ b/tests/Api/V3/DuplicateGlobalClassTest.php
@@ -4,6 +4,7 @@
namespace Tests\Api\V3;
+use Tests\Support\SourceScan;
use Tests\TestCase;
/**
@@ -45,33 +46,19 @@ final class DuplicateGlobalClassTest extends TestCase
/** @return array global class name => files declaring it */
private function globalClassDeclarations(): array
{
- $root = dirname(__DIR__, 3);
$found = [];
-
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveCallbackFilterIterator(
- new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
- static function (\SplFileInfo $file): bool {
- $name = $file->getFilename();
- return !in_array($name, ['vendor', 'node_modules', '.git'], true);
- }
- )
- );
-
- foreach ($iterator as $file) {
- if (!$file->isFile() || $file->getExtension() !== 'php') {
- continue;
- }
- $source = (string)file_get_contents($file->getPathname());
+ // Tests are included: a test that declares a global stub class collides
+ // with the real one just as any other file would.
+ foreach (SourceScan::phpFiles(includeTests: true) as $path => $source) {
// Namespaced classes cannot collide with global ones.
if (preg_match('/^\s*namespace\s+[^;{\s]+/m', $source) === 1) {
continue;
}
- if (preg_match_all('/^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_]\w*)/m', $source, $matches) < 1) {
+ if (SourceScan::countMatches('/^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_]\w*)/m', $source, $path, $matches) < 1) {
continue;
}
foreach ($matches[1] as $class) {
- $found[$class][] = str_replace($root . '/', '', $file->getPathname());
+ $found[$class][] = $path;
}
}
diff --git a/tests/Api/V3/UncheckedExecuteTest.php b/tests/Api/V3/UncheckedExecuteTest.php
index 29d7ccb2..a28c1c6e 100644
--- a/tests/Api/V3/UncheckedExecuteTest.php
+++ b/tests/Api/V3/UncheckedExecuteTest.php
@@ -4,21 +4,27 @@
namespace Tests\Api\V3;
+use Tests\Support\SourceScan;
use Tests\TestCase;
/**
* A bare `$stmt->execute();` discards the return value. Under the error mode
* the app actually sets (connect.php calls mysqli_report(MYSQLI_REPORT_STRICT)
* alone, not the ERROR|STRICT default), a failed execute RETURNS FALSE rather
- * than throwing — so the failure is silent, and whatever the code does next
+ * than throwing -- so the failure is silent, and whatever the code does next
* runs on the assumption that the statement succeeded. In a batch loop that
* reads as "no more rows"; in a dedupe guard it reads as "not yet processed".
*
* ForbidDirectMysqliStmtCallRule covers some of this, but only where PHPStan
- * can infer the caller is a mysqli_stmt — which legacy code often does not
- * allow — and it flags every direct call, checked or not. This test is the
- * complement: purely textual, so inference cannot hide anything from it, and
- * concerned only with whether the result is used.
+ * can infer the caller is a mysqli_stmt -- which legacy code often does not
+ * allow -- and it flags every direct call, checked or not. This test is the
+ * complement: token-based rather than typed, so inference cannot hide anything
+ * from it, and concerned only with whether the result is used.
+ *
+ * Only zero-argument calls count. The checked wrappers are also named
+ * execute() -- Connection::execute($stmt), StatementHelpers::execute($stmt,
+ * $message) -- and are told apart from a raw mysqli_stmt::execute() by taking
+ * arguments.
*/
final class UncheckedExecuteTest extends TestCase
{
@@ -37,45 +43,23 @@ final class UncheckedExecuteTest extends TestCase
'202-config/Attribution/AttributionIntegrationService.php',
];
- /** @return array file (repo-relative) => count of bare execute() calls */
+ /** @return array> file (repo-relative) => lines of bare execute() calls */
private function bareExecuteCalls(): array
{
- $root = dirname(__DIR__, 3);
$found = [];
-
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveCallbackFilterIterator(
- new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
- static function (\SplFileInfo $file): bool {
- // Tests may exercise failure modes deliberately.
- return !in_array($file->getFilename(), ['vendor', 'node_modules', '.git', 'tests'], true);
- }
- )
- );
-
- foreach ($iterator as $file) {
- if (!$file->isFile() || $file->getExtension() !== 'php') {
+ foreach (SourceScan::phpFiles() as $path => $source) {
+ if (!str_contains($source, '->execute(')) {
continue;
}
- $source = (string)file_get_contents($file->getPathname());
- // A statement whose entire content is the call: no if(), no
- // assignment, no return, no boolean operator.
- $count = preg_match_all('/^[ \t]*\$[A-Za-z_]\w*->execute\(\s*\);[ \t]*$/m', $source);
- if ($count > 0) {
- $found[str_replace($root . '/', '', $file->getPathname())] = $count;
+ $lines = SourceScan::uncheckedCallStatements($source, ['execute'], [], true);
+ if ($lines !== []) {
+ $found[$path] = $lines;
}
}
return $found;
}
- public function testTheScannerWorks(): void
- {
- // The pattern must actually match the shape it claims to.
- $sample = " \$stmt->execute();\n if (!\$other->execute()) {\n";
- $this->assertSame(1, preg_match_all('/^[ \t]*\$[A-Za-z_]\w*->execute\(\s*\);[ \t]*$/m', $sample));
- }
-
public function testNoNewUncheckedExecuteIsIntroduced(): void
{
$unexpected = array_diff_key($this->bareExecuteCalls(), array_flip(self::KNOWN_UNCHECKED));
@@ -86,7 +70,7 @@ public function testNoNewUncheckedExecuteIsIntroduced(): void
. 'carries on as though the statement succeeded. Check the return and fail, warn, or '
. 'recover explicitly.',
implode("\n", array_map(
- static fn(string $f, int $n): string => " $f ($n)",
+ static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines),
array_keys($unexpected),
$unexpected
))
@@ -100,7 +84,7 @@ public function testTheKnownListHasNoStaleEntries(): void
$this->assertArrayHasKey(
$file,
$current,
- "$file no longer has an unchecked execute() — remove it from KNOWN_UNCHECKED."
+ "$file no longer has an unchecked execute() -- remove it from KNOWN_UNCHECKED."
);
}
}
diff --git a/tests/Api/V3/UncheckedTransactionBoundaryTest.php b/tests/Api/V3/UncheckedTransactionBoundaryTest.php
new file mode 100644
index 00000000..379631f7
--- /dev/null
+++ b/tests/Api/V3/UncheckedTransactionBoundaryTest.php
@@ -0,0 +1,68 @@
+ $source) {
+ $lines = SourceScan::uncheckedCallStatements($source, self::METHODS, self::FUNCTIONS);
+ if ($lines !== []) {
+ $found[$path] = $lines;
+ }
+ }
+
+ self::assertSame([], $found, sprintf(
+ "These open or close a transaction without checking the result:\n%s\n"
+ . 'Under MYSQLI_REPORT_STRICT both return false instead of throwing. An unchecked '
+ . 'begin_transaction() silently downgrades the block to autocommit, so the matching '
+ . 'rollback() undoes nothing; an unchecked commit() reports success for work that was '
+ . 'never written. Check the result, or use Connection::transaction() / '
+ . 'StatementHelpers::transaction().',
+ implode("\n", array_map(
+ static fn(string $f, array $lines): string => " $f: line " . implode(', ', $lines),
+ array_keys($found),
+ $found
+ ))
+ ));
+ }
+}
diff --git a/tests/Attribution/AttributionRepositoryTest.php b/tests/Attribution/AttributionRepositoryTest.php
index ee73987e..6a5d7303 100644
--- a/tests/Attribution/AttributionRepositoryTest.php
+++ b/tests/Attribution/AttributionRepositoryTest.php
@@ -173,31 +173,26 @@ public function testListSnapshotsFiltersByScopeType(): void
// --- Exports ---
- public function testScheduleExportCreatesRecord(): void
+ public function testListExportsReturnsTheSeededRecord(): void
{
$repo = $this->makeRepo();
- $id = $repo->scheduleExport(1, 1, [
- 'scope_type' => 'campaign',
- 'scope_id' => 5,
- 'format' => 'csv',
- ]);
+ $id = $repo->seedExport(1, 1, ['scope_type' => 'campaign', 'scope_id' => 5]);
$exports = $repo->listExports(1, 1);
self::assertCount(1, $exports);
self::assertSame($id, $exports[0]['export_id']);
- self::assertSame('queued', $exports[0]['status']);
+ self::assertSame('pending', $exports[0]['status']);
self::assertSame('campaign', $exports[0]['scope_type']);
}
public function testListExportsFiltersByModelAndUser(): void
{
$repo = $this->makeRepo();
- $repo->scheduleExport(1, 1, []);
- $repo->scheduleExport(2, 1, []);
-
- $exports = $repo->listExports(1, 1);
+ $repo->seedExport(1, 1);
+ $repo->seedExport(2, 1);
+ $repo->seedExport(1, 2);
- self::assertCount(1, $exports);
+ self::assertCount(1, $repo->listExports(1, 1));
}
}
diff --git a/tests/Attribution/AttributionServiceExportTest.php b/tests/Attribution/AttributionServiceExportTest.php
index 0419788b..ce66f732 100644
--- a/tests/Attribution/AttributionServiceExportTest.php
+++ b/tests/Attribution/AttributionServiceExportTest.php
@@ -53,7 +53,11 @@ public function testScheduleSnapshotExportPersistsPendingJob(): void
'end_hour' => $now,
'format' => ExportFormat::CSV->value,
'webhook' => [
- 'url' => 'https://example.com/hook',
+ // An IP literal, not a hostname: scheduleSnapshotExport() now
+ // runs the SSRF guard, and a hostname would make this test
+ // depend on live DNS. 203.0.113.0/24 is TEST-NET-3 -- routable
+ // as far as the guard is concerned, and never contacted here.
+ 'url' => 'https://203.0.113.10/hook',
'headers' => ['X-Test' => ' value '],
],
]);
@@ -68,13 +72,44 @@ public function testScheduleSnapshotExportPersistsPendingJob(): void
$job = $jobs[0];
$this->assertSame(ExportStatus::PENDING, $job->status);
$this->assertNotNull($job->webhook);
- $this->assertSame('https://example.com/hook', $job->webhook->url);
+ $this->assertSame('https://203.0.113.10/hook', $job->webhook->url);
// ExportWebhook stores header values verbatim (no trimming).
$this->assertSame(['X-Test' => ' value '], $job->webhook->headers);
$this->assertSame($now - 7200, $job->startHour);
$this->assertSame($now, $job->endHour);
}
+ /**
+ * @dataProvider blockedWebhookUrls
+ */
+ public function testScheduleSnapshotExportRejectsAnUnsafeWebhookUrl(string $url): void
+ {
+ // The guard used to live in ExportWebhook's constructor, which is also
+ // the row-hydration path -- so it was moved here, to the write boundary.
+ // It has to still fire, or the move traded one bug for a worse one.
+ $now = (int) floor(time() / 3600) * 3600;
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->scheduleSnapshotExport(1, 1, [
+ 'scope' => ScopeType::GLOBAL->value,
+ 'start_hour' => $now - 7200,
+ 'end_hour' => $now,
+ 'format' => ExportFormat::CSV->value,
+ 'webhook' => ['url' => $url],
+ ]);
+ }
+
+ /** @return array */
+ public static function blockedWebhookUrls(): array
+ {
+ return [
+ 'cleartext' => ['http://203.0.113.10/hook'],
+ 'loopback' => ['https://127.0.0.1/hook'],
+ 'link local' => ['https://169.254.169.254/hook'],
+ 'private range' => ['https://10.0.0.5/hook'],
+ ];
+ }
+
public function testScheduleSnapshotExportRejectsInvalidWindow(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/Attribution/Export/ExportProcessorTest.php b/tests/Attribution/Export/ExportProcessorTest.php
deleted file mode 100644
index 7d2413eb..00000000
--- a/tests/Attribution/Export/ExportProcessorTest.php
+++ /dev/null
@@ -1,161 +0,0 @@
-exportRepository = new InMemoryExportRepository(fn (): int => 1_700_000_100);
- $this->modelRepository = new InMemoryModelRepository();
- $this->snapshotRepository = new InMemorySnapshotRepository();
-
- $this->exportPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'prosper202-export-tests';
- if (is_dir($this->exportPath)) {
- $this->recursiveDelete($this->exportPath);
- }
-
- $this->snapshotExporter = new SnapshotExporter($this->exportPath);
- $this->webhookDispatcher = new WebhookDispatcher();
-
- $this->processor = new ExportProcessor(
- $this->exportRepository,
- $this->snapshotRepository,
- $this->modelRepository,
- $this->snapshotExporter,
- $this->webhookDispatcher
- );
- }
-
- /**
- * Builds and persists a pending export job via the repository, mirroring how
- * production code enqueues work for the processor to pick up.
- */
- private function schedulePendingExport(int $startHour, int $endHour): ExportJob
- {
- $now = 1_700_000_000;
-
- return $this->exportRepository->create(new ExportJob(
- exportId: null,
- userId: 1,
- modelId: 1,
- scopeType: ScopeType::GLOBAL,
- scopeId: null,
- startHour: $startHour,
- endHour: $endHour,
- format: ExportFormat::CSV,
- status: ExportStatus::PENDING,
- filePath: null,
- downloadToken: null,
- webhookUrl: null,
- webhookMethod: 'POST',
- webhookHeaders: [],
- webhookStatusCode: null,
- webhookResponseBody: null,
- lastAttemptedAt: null,
- completedAt: null,
- errorMessage: null,
- createdAt: $now,
- updatedAt: $now
- ));
- }
-
- protected function tearDown(): void
- {
- if (is_dir($this->exportPath)) {
- $this->recursiveDelete($this->exportPath);
- }
-
- parent::tearDown();
- }
-
- public function testProcessPendingCompletesJob(): void
- {
- $now = (int) floor(time() / 3600) * 3600;
- $this->schedulePendingExport($now - 7200, $now);
-
- $results = $this->processor->processPending(5);
-
- $this->assertCount(1, $results);
- $this->assertSame('completed', $results[0]['status']);
- $this->assertArrayHasKey('export_id', $results[0]);
-
- $jobs = $this->exportRepository->findForUser(1);
- $this->assertCount(1, $jobs);
- $job = $jobs[0];
- $this->assertSame('completed', $job->status->value);
- $this->assertNotNull($job->filePath);
- $this->assertFileExists($job->filePath);
- }
-
- public function testProcessPendingMarksJobsFailedWhenModelMissing(): void
- {
- $now = (int) floor(time() / 3600) * 3600;
- $this->schedulePendingExport($now - 3600, $now);
-
- $this->modelRepository->delete(1, 1);
-
- $results = $this->processor->processPending(5);
-
- $this->assertCount(1, $results);
- $this->assertSame('failed', $results[0]['status']);
- $this->assertStringContainsString('no longer available', (string) $results[0]['error']);
-
- $jobs = $this->exportRepository->findForUser(1);
- $this->assertSame('failed', $jobs[0]->status->value);
- }
-
- private function recursiveDelete(string $path): void
- {
- if (!is_dir($path)) {
- return;
- }
-
- $items = scandir($path);
- if ($items === false) {
- return;
- }
-
- foreach ($items as $item) {
- if ($item === '.' || $item === '..') {
- continue;
- }
-
- $full = $path . DIRECTORY_SEPARATOR . $item;
- if (is_dir($full)) {
- $this->recursiveDelete($full);
- } elseif (is_file($full)) {
- @unlink($full);
- }
- }
-
- @rmdir($path);
- }
-}
diff --git a/tests/Attribution/Support/RepositoryFakes.php b/tests/Attribution/Support/RepositoryFakes.php
index e0b550ab..b1792bd9 100644
--- a/tests/Attribution/Support/RepositoryFakes.php
+++ b/tests/Attribution/Support/RepositoryFakes.php
@@ -9,10 +9,6 @@
use Prosper202\Attribution\Repository\ModelRepositoryInterface;
use Prosper202\Attribution\Repository\SnapshotRepositoryInterface;
use Prosper202\Attribution\Repository\TouchpointRepositoryInterface;
-use Prosper202\Attribution\Export\ExportFormat;
-use Prosper202\Attribution\Export\ExportJob;
-use Prosper202\Attribution\Export\ExportStatus;
-use Prosper202\Attribution\Repository\ExportRepositoryInterface;
use Prosper202\Attribution\ScopeType;
use Prosper202\Attribution\Snapshot;
use Prosper202\Attribution\Touchpoint;
@@ -300,111 +296,3 @@ public function deleteBySnapshot(int $snapshotId): void
unset($this->touchpoints[$snapshotId]);
}
}
-
-/**
- * In-memory fake for {@see ExportRepositoryInterface}, backed by the mutable
- * Prosper202\Attribution\Export\ExportJob value object that ExportProcessor
- * operates on. Job objects are stored by reference so that in-place mutations
- * performed by the processor (markCompleted/markFailed) are reflected once the
- * processor calls update(), mirroring the behaviour of MysqlExportRepository.
- *
- * Used exclusively by ExportProcessorTest.
- */
-final class InMemoryExportRepository implements ExportRepositoryInterface
-{
- /**
- * @var array
- */
- private array $jobs = [];
-
- private int $nextId = 1;
-
- /** @var callable():int|null */
- private $clock;
-
- /**
- * @param callable():int|null $clock
- */
- public function __construct(?callable $clock = null)
- {
- $this->clock = $clock;
- }
-
- public function create(ExportJob $job): ExportJob
- {
- $job->exportId = $this->nextId++;
- $this->jobs[$job->exportId] = $job;
-
- return $job;
- }
-
- public function update(ExportJob $job): ExportJob
- {
- if ($job->exportId === null) {
- return $job;
- }
-
- $this->jobs[$job->exportId] = $job;
-
- return $job;
- }
-
- public function findById(int $exportId): ?ExportJob
- {
- return $this->jobs[$exportId] ?? null;
- }
-
- /**
- * @return ExportJob[]
- */
- public function findForUser(int $userId, ?int $modelId = null, int $limit = 25): array
- {
- $limit = max(1, $limit);
-
- $filtered = array_filter(
- $this->jobs,
- static function (ExportJob $job) use ($userId, $modelId): bool {
- if ($job->userId !== $userId) {
- return false;
- }
-
- return $modelId === null || $job->modelId === $modelId;
- }
- );
-
- usort($filtered, static fn (ExportJob $a, ExportJob $b): int => $b->createdAt <=> $a->createdAt);
-
- return array_slice(array_values($filtered), 0, $limit);
- }
-
- /**
- * Claims a batch of pending jobs and marks them as processing, returning the
- * stored job instances so subsequent mutations and update() calls persist.
- *
- * @return ExportJob[]
- */
- public function claimPending(int $limit = 10): array
- {
- $limit = max(1, $limit);
-
- $pending = array_filter(
- $this->jobs,
- static fn (ExportJob $job): bool => $job->status === ExportStatus::PENDING
- );
-
- usort($pending, static fn (ExportJob $a, ExportJob $b): int => $a->createdAt <=> $b->createdAt);
- $batch = array_slice($pending, 0, $limit);
-
- $now = $this->now();
- foreach ($batch as $job) {
- $job->markProcessing($now);
- }
-
- return $batch;
- }
-
- private function now(): int
- {
- return $this->clock !== null ? ($this->clock)() : time();
- }
-}
diff --git a/tests/Cli/Commands/CrudCommandsTest.php b/tests/Cli/Commands/CrudCommandsTest.php
index d33b6d66..518842e0 100644
--- a/tests/Cli/Commands/CrudCommandsTest.php
+++ b/tests/Cli/Commands/CrudCommandsTest.php
@@ -278,7 +278,7 @@ public function testAttributionCreateRejectsInvalidWeightingConfigJson(): void
]);
$this->assertSame(Command::FAILURE, $status);
- $this->assertStringContainsString('Invalid --weighting_config JSON', $tester->getDisplay());
+ $this->assertStringContainsString('Invalid JSON in --weighting_config', $tester->getDisplay());
}
public function testAttributionUpdateRejectsInvalidWeightingConfigJson(): void
@@ -294,6 +294,6 @@ public function testAttributionUpdateRejectsInvalidWeightingConfigJson(): void
]);
$this->assertSame(Command::FAILURE, $status);
- $this->assertStringContainsString('Invalid --weighting_config JSON', $tester->getDisplay());
+ $this->assertStringContainsString('Invalid JSON in --weighting_config', $tester->getDisplay());
}
}
diff --git a/tests/Database/ConnectionTest.php b/tests/Database/ConnectionTest.php
index 55681f9b..9fb01a0e 100644
--- a/tests/Database/ConnectionTest.php
+++ b/tests/Database/ConnectionTest.php
@@ -9,6 +9,7 @@
use mysqli_stmt;
use PHPUnit\Framework\TestCase;
use Prosper202\Database\Connection;
+use Prosper202\Database\Exceptions\QueryException;
use RuntimeException;
/**
@@ -115,8 +116,11 @@ public function testFetchOneReturnsNullForEmptyResult(): void
$this->assertNull($conn->fetchOne($stmt));
}
- public function testFetchOneReturnsNullWhenGetResultReturnsFalse(): void
+ public function testFetchOneReturnsNullWhenThereIsNoResultSetAndNoError(): void
{
+ // get_result() is false and the statement reports no errno: a statement
+ // that simply produced no result set. (A mock cannot expose errno, so
+ // Connection reads it as 0 -- the same as a real INSERT would report.)
$stmt = $this->createMock(mysqli_stmt::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('get_result')->willReturn(false);
@@ -126,6 +130,24 @@ public function testFetchOneReturnsNullWhenGetResultReturnsFalse(): void
$this->assertNull($conn->fetchOne($stmt));
}
+ public function testFetchOneThrowsWhenGetResultFailsWithAnError(): void
+ {
+ // The other meaning of a false get_result(): the fetch itself failed
+ // (server gone away mid-query, errno 2013). Reading that as "no rows"
+ // is how a free-id check reports a taken id as free. mysqli_stmt::$errno
+ // cannot be set on a fake, so the reader is the seam.
+ $stmt = $this->createMock(mysqli_stmt::class);
+ $stmt->method('execute')->willReturn(true);
+ $stmt->method('get_result')->willReturn(false);
+ $stmt->expects($this->once())->method('close');
+
+ $conn = $this->connectionReportingStatementErrno(2013, 'Lost connection to server during query');
+
+ $this->expectException(QueryException::class);
+ $this->expectExceptionMessage('MySQL get_result failed: Lost connection to server during query [errno 2013]');
+ $conn->fetchOne($stmt);
+ }
+
// ── FetchAll ─────────────────────────────────────────────────────
public function testFetchAllReturnsAllRowsAndClosesStatement(): void
@@ -152,7 +174,7 @@ public function testFetchAllReturnsAllRowsAndClosesStatement(): void
$this->assertSame($rows, $conn->fetchAll($stmt));
}
- public function testFetchAllReturnsEmptyArrayWhenGetResultReturnsFalse(): void
+ public function testFetchAllReturnsEmptyArrayWhenThereIsNoResultSetAndNoError(): void
{
$stmt = $this->createMock(mysqli_stmt::class);
$stmt->method('execute')->willReturn(true);
@@ -163,6 +185,46 @@ public function testFetchAllReturnsEmptyArrayWhenGetResultReturnsFalse(): void
$this->assertSame([], $conn->fetchAll($stmt));
}
+ public function testFetchAllThrowsWhenGetResultFailsWithAnError(): void
+ {
+ // A batch loop reading this as [] exits early and reports success.
+ $stmt = $this->createMock(mysqli_stmt::class);
+ $stmt->method('execute')->willReturn(true);
+ $stmt->method('get_result')->willReturn(false);
+ $stmt->expects($this->once())->method('close');
+
+ $conn = $this->connectionReportingStatementErrno(2006, 'MySQL server has gone away');
+
+ $this->expectException(QueryException::class);
+ $this->expectExceptionMessage('[errno 2006]');
+ $conn->fetchAll($stmt);
+ }
+
+ /**
+ * A Connection whose statement-error readers report the given values.
+ * Native mysqli_stmt::$errno/$error throw on every constructor-skipping
+ * fake, so this is the only way to exercise the failed-fetch branch.
+ */
+ private function connectionReportingStatementErrno(int $errno, string $error): Connection
+ {
+ return new class($this->createFakeMysqli(), $errno, $error) extends Connection {
+ public function __construct(\mysqli $write, private int $fakeErrno, private string $fakeError)
+ {
+ parent::__construct($write);
+ }
+
+ protected function statementErrno(object $stmt): int
+ {
+ return $this->fakeErrno;
+ }
+
+ protected function statementError(object $stmt): string
+ {
+ return $this->fakeError;
+ }
+ };
+ }
+
// ── ExecuteInsert ────────────────────────────────────────────────
public function testExecuteInsertReturnsInsertId(): void
diff --git a/tests/Ltv/LtvProductPredictionIntegrationTest.php b/tests/Ltv/LtvProductPredictionIntegrationTest.php
new file mode 100644
index 00000000..a6644281
--- /dev/null
+++ b/tests/Ltv/LtvProductPredictionIntegrationTest.php
@@ -0,0 +1,262 @@
+query($sql); }');
+ }
+ mysqli_report(MYSQLI_REPORT_STRICT);
+
+ $db = @mysqli_connect(
+ $host,
+ (string) (getenv('P202_TEST_DB_USER') ?: 'root'),
+ (string) (getenv('P202_TEST_DB_PASS') ?: ''),
+ (string) (getenv('P202_TEST_DB_NAME') ?: 'prosper202'),
+ (int) (getenv('P202_TEST_DB_PORT') ?: 3306)
+ );
+ if (!$db) {
+ return;
+ }
+ $db->query("SET SESSION sql_mode=''");
+ (new SchemaInstaller($db))->install();
+ self::$db = $db;
+ self::$conn = new Connection($db);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ if (self::$db) {
+ self::$db->close();
+ self::$db = null;
+ self::$conn = null;
+ }
+ }
+
+ protected function setUp(): void
+ {
+ if (self::$db === null) {
+ self::markTestSkipped('No test database configured (P202_TEST_DB_HOST).');
+ }
+ foreach (['202_revenue_events', '202_revenue_line_items', '202_products', '202_subscriptions', '202_customers'] as $t) {
+ self::$db->query("TRUNCATE TABLE {$t}");
+ }
+ $this->eventSeq = 0;
+ }
+
+ // ── Fixture helpers ─────────────────────────────────────────────────
+
+ private function product(int $id, string $name): void
+ {
+ self::$db->query(
+ "INSERT INTO 202_products SET product_id={$id}, user_id=1, external_product_id='ext-{$id}', name='" .
+ self::$db->real_escape_string($name) . "', created_at=1, updated_at=1"
+ );
+ }
+
+ /** One purchase order: an event with a single product line item. */
+ private function order(int $customerId, int $productId, float $amount, float $qty = 1.0, string $type = 'purchase'): int
+ {
+ $eventId = ++$this->eventSeq + 100000;
+ self::$db->query(
+ "INSERT INTO 202_revenue_events SET event_id={$eventId}, user_id=1, customer_id={$customerId}, " .
+ "event_type='{$type}', amount={$amount}, occurred_at=1700000000, source='api', created_at=1"
+ );
+ self::$db->query(
+ "INSERT INTO 202_revenue_line_items SET user_id=1, event_id={$eventId}, product_id={$productId}, " .
+ "quantity={$qty}, amount={$amount}, created_at=1"
+ );
+ return $eventId;
+ }
+
+ /**
+ * An active subscription whose renewal event bills for the given products
+ * (a bundle when more than one), each as its own line item.
+ *
+ * @param list $productIds
+ */
+ private function activeSubscription(int $customerId, float $mrr, array $productIds, string $status = 'active'): void
+ {
+ $subId = ++$this->eventSeq + 500000;
+ self::$db->query(
+ "INSERT INTO 202_subscriptions SET subscription_id={$subId}, user_id=1, customer_id={$customerId}, " .
+ "external_sub_id='sub-{$subId}', amount={$mrr}, status='{$status}', mrr={$mrr}, started_at=1, " .
+ "current_period_start=1, current_period_end=2, created_at=1, updated_at=1"
+ );
+ $eventId = ++$this->eventSeq + 100000;
+ self::$db->query(
+ "INSERT INTO 202_revenue_events SET event_id={$eventId}, user_id=1, customer_id={$customerId}, " .
+ "event_type='renewal', amount={$mrr}, occurred_at=1700000000, source='subscription', " .
+ "subscription_id={$subId}, created_at=1"
+ );
+ foreach ($productIds as $pid) {
+ self::$db->query(
+ "INSERT INTO 202_revenue_line_items SET user_id=1, event_id={$eventId}, product_id={$pid}, " .
+ "quantity=1, amount=" . ($mrr / count($productIds)) . ", created_at=1"
+ );
+ }
+ }
+
+ private function repo(): MysqlLtvRepository
+ {
+ return new MysqlLtvRepository(self::$conn);
+ }
+
+ /** @return array|null */
+ private function productRow(int $productId): ?array
+ {
+ foreach ($this->repo()->breakdown(new LtvQuery(1), 'product', 100, 0) as $row) {
+ if ((int) $row['id'] === $productId) {
+ return $row;
+ }
+ }
+ return null;
+ }
+
+ // ── Tests ───────────────────────────────────────────────────────────
+
+ public function testBreakdownComputesAovAndRepeatRate(): void
+ {
+ $this->product(1, 'Widget');
+ // customer 1001 buys twice ($10 + $10), 1002 and 1003 once each ($10).
+ $this->order(1001, 1, 10.0);
+ $this->order(1001, 1, 10.0);
+ $this->order(1002, 1, 10.0);
+ $this->order(1003, 1, 10.0);
+
+ $row = $this->productRow(1);
+ self::assertNotNull($row);
+ self::assertSame(3, (int) $row['customers']);
+ self::assertSame(4, (int) $row['orders']);
+ self::assertEqualsWithDelta(40.0, (float) $row['total_revenue'], 1e-6);
+ self::assertEqualsWithDelta(10.0, (float) $row['aov'], 1e-6); // 40 / 4
+ // 1 of 3 customers repeat. MySQL division carries div_precision_increment
+ // (default 4) decimals, so 1/3 -> 0.3333 — the same precision the
+ // existing acquisition breakdown produces for this expression.
+ self::assertEqualsWithDelta(1 / 3, (float) $row['repeat_rate'], 1e-4);
+ self::assertEqualsWithDelta(0.0, (float) $row['mrr'], 1e-6);
+ }
+
+ public function testSubscriberMrrAttributedToSingleProduct(): void
+ {
+ $this->product(1, 'Widget');
+ $this->order(1001, 1, 10.0);
+ $this->activeSubscription(1001, 30.0, [1]);
+
+ $row = $this->productRow(1);
+ self::assertNotNull($row);
+ self::assertEqualsWithDelta(30.0, (float) $row['mrr'], 1e-6);
+ }
+
+ public function testBundleSubscriberMrrSplitEvenlyAndReconciles(): void
+ {
+ $this->product(1, 'Widget');
+ $this->product(2, 'Gadget');
+ $this->order(1001, 1, 10.0);
+ $this->order(1002, 2, 10.0);
+ // One $50/mo subscription billing for both products -> $25 each.
+ $this->activeSubscription(1001, 50.0, [1, 2]);
+
+ $mrr1 = (float) $this->productRow(1)['mrr'];
+ $mrr2 = (float) $this->productRow(2)['mrr'];
+ self::assertEqualsWithDelta(25.0, $mrr1, 1e-6);
+ self::assertEqualsWithDelta(25.0, $mrr2, 1e-6);
+ // Additive: product MRR reconciles to the subscription's total.
+ self::assertEqualsWithDelta(50.0, $mrr1 + $mrr2, 1e-6);
+ }
+
+ public function testCanceledSubscriptionContributesNoProductMrr(): void
+ {
+ $this->product(1, 'Widget');
+ $this->order(1001, 1, 10.0);
+ $this->activeSubscription(1001, 30.0, [1], 'canceled');
+
+ self::assertEqualsWithDelta(0.0, (float) $this->productRow(1)['mrr'], 1e-6);
+ }
+
+ public function testTimeWindowScopesRevenueButNotSubscriberState(): void
+ {
+ $this->product(1, 'Widget');
+ // In-window order and an out-of-window one for the same customer.
+ $this->order(1001, 1, 10.0); // occurred_at = 1700000000
+ self::$db->query(
+ "INSERT INTO 202_revenue_events SET event_id=999001, user_id=1, customer_id=1002, " .
+ "event_type='purchase', amount=999, occurred_at=1600000000, source='api', created_at=1"
+ );
+ self::$db->query(
+ "INSERT INTO 202_revenue_line_items SET user_id=1, event_id=999001, product_id=1, quantity=1, amount=999, created_at=1"
+ );
+
+ $rows = $this->repo()->breakdown(new LtvQuery(1, 1699999999, 1700000001), 'product', 100, 0);
+ $row = null;
+ foreach ($rows as $r) {
+ if ((int) $r['id'] === 1) {
+ $row = $r;
+ }
+ }
+ self::assertNotNull($row);
+ self::assertSame(1, (int) $row['customers']); // out-of-window customer excluded
+ self::assertEqualsWithDelta(10.0, (float) $row['total_revenue'], 1e-6);
+ }
+
+ public function testPredictUsesRealCohortProjectionForLargeProduct(): void
+ {
+ $this->product(1, 'Bestseller');
+ // 20 customers, one $10 order each; 5 of them buy a second time.
+ for ($c = 1; $c <= 20; $c++) {
+ $this->order(2000 + $c, 1, 10.0);
+ }
+ for ($c = 1; $c <= 5; $c++) {
+ $this->order(2000 + $c, 1, 10.0);
+ }
+
+ $result = $this->repo()->predict(new LtvQuery(1), 'product');
+ $productRow = null;
+ foreach ($result['breakdown'] as $r) {
+ if ((int) $r['id'] === 1) {
+ $productRow = $r;
+ }
+ }
+ self::assertNotNull($productRow);
+ $prediction = $productRow['prediction'];
+
+ // The regression: this used to be 'account_fallback' with a $0 (or
+ // account-average) number. It must now be a real per-product cohort.
+ self::assertSame('cohort', $prediction['basis']);
+ self::assertEqualsWithDelta(0.25, (float) $prediction['inputs']['repeat_rate'], 1e-6);
+ self::assertEqualsWithDelta(10.0, (float) $prediction['inputs']['aov'], 1e-6);
+ // aov / (1 - repeat_rate) = 10 / 0.75 = 13.3333...
+ self::assertEqualsWithDelta(13.33333, (float) $prediction['predicted_ltv_per_customer'], 1e-4);
+ }
+}
diff --git a/tests/Messaging/MessagingTransportAllowlistTest.php b/tests/Messaging/MessagingTransportAllowlistTest.php
new file mode 100644
index 00000000..3e271691
--- /dev/null
+++ b/tests/Messaging/MessagingTransportAllowlistTest.php
@@ -0,0 +1,113 @@
+decide = new ReflectionMethod(\MessagingClient::class, 'transportProtocols');
+ $this->decide->setAccessible(true);
+ }
+
+ /**
+ * @dataProvider decisions
+ */
+ public function testTheTransportDecision(string $url, ?int $expected): void
+ {
+ self::assertSame($expected, $this->decide->invoke(null, $url), $url);
+ }
+
+ /** @return array */
+ public static function decisions(): array
+ {
+ $httpsOnly = CURLPROTO_HTTPS;
+ $loopback = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+
+ return [
+ 'central https' => ['https://my.tracking202.com/api/v3/messaging', $httpsOnly],
+ 'https any host' => ['https://10.0.0.9/messaging', $httpsOnly],
+ 'documented mock' => ['http://127.0.0.1:8787/messaging', $loopback],
+ 'loopback name' => ['http://localhost:8787/messaging', $loopback],
+ 'loopback v6' => ['http://[::1]:8787/messaging', $loopback],
+ 'loopback 127.x' => ['http://127.5.5.5:8787/messaging', $loopback],
+ 'uppercase scheme' => ['HTTP://127.0.0.1:8787/messaging', $loopback],
+ 'padded' => [" http://127.0.0.1:8787/messaging ", $loopback],
+ 'private lan' => ['http://10.0.0.9/messaging', null],
+ 'public cleartext' => ['http://my.tracking202.com/api/v3/messaging', null],
+ 'no scheme' => ['my.tracking202.com/api/v3/messaging', null],
+ 'empty' => ['', null],
+ 'http no host' => ['http:///messaging', null],
+ ];
+ }
+
+ public function testCleartextIsNeverGrantedToARefusedOrHttpsUrl(): void
+ {
+ // The security property, stated independently of the table above: HTTP
+ // appears in the mask only for an accepted loopback URL.
+ foreach (self::decisions() as $name => [$url, $expected]) {
+ $mask = $this->decide->invoke(null, $url);
+ if ($mask === null) {
+ continue;
+ }
+ self::assertNotSame(0, $mask & CURLPROTO_HTTPS, "$name: HTTPS must always be permitted");
+ $grantsHttp = ($mask & CURLPROTO_HTTP) !== 0;
+ $isLoopbackCleartext = str_starts_with(strtolower(trim($url)), 'http://');
+ self::assertSame($isLoopbackCleartext, $grantsHttp, "$name: cleartext must be granted exactly to accepted http:// (loopback) URLs");
+ }
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function testTheConstructorStoresTheTrimmedUrlAndTheMatchingProtocols(): void
+ {
+ define('MESSAGING_API_URL', " http://127.0.0.1:8787/messaging ");
+ require_once __DIR__ . '/../../202-config/Messaging/MessagingClient.class.php';
+
+ $client = new \MessagingClient();
+
+ $baseUrl = new ReflectionProperty(\MessagingClient::class, 'baseUrl');
+ $baseUrl->setAccessible(true);
+ self::assertSame('http://127.0.0.1:8787/messaging', $baseUrl->getValue($client), 'a padded URL must not reach curl padded');
+
+ $protocols = new ReflectionProperty(\MessagingClient::class, 'curlProtocols');
+ $protocols->setAccessible(true);
+ self::assertSame(CURLPROTO_HTTPS | CURLPROTO_HTTP, $protocols->getValue($client));
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function testTheConstructorRefusesACleartextUrlThatIsNotLoopback(): void
+ {
+ define('MESSAGING_API_URL', 'http://my.tracking202.com/api/v3/messaging');
+ require_once __DIR__ . '/../../202-config/Messaging/MessagingClient.class.php';
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('refusing to send credentials in cleartext');
+ new \MessagingClient();
+ }
+}
diff --git a/tests/Report/CampaignDataMaskTest.php b/tests/Report/CampaignDataMaskTest.php
new file mode 100644
index 00000000..863fb8ee
--- /dev/null
+++ b/tests/Report/CampaignDataMaskTest.php
@@ -0,0 +1,207 @@
+granted : false;
+ }
+ };
+ }
+
+ public function testHiddenWhenTheUserLacksThePermission(): void
+ {
+ $GLOBALS['userObj'] = self::userWithPermission(false);
+ self::assertTrue(CampaignDataMask::hidden());
+ }
+
+ public function testNotHiddenWhenGranted(): void
+ {
+ $GLOBALS['userObj'] = self::userWithPermission(true);
+ self::assertFalse(CampaignDataMask::hidden());
+ }
+
+ public function testPublishersAreExemptAndNoUserMeansNothingToHide(): void
+ {
+ $GLOBALS['userObj'] = self::userWithPermission(false);
+ $_SESSION['publisher'] = 1;
+ self::assertFalse(CampaignDataMask::hidden(), 'a publisher session is already scoped to its own data');
+
+ unset($_SESSION['publisher']);
+ $GLOBALS['userObj'] = null;
+ self::assertFalse(CampaignDataMask::hidden());
+ }
+
+ public function testApplyMasksTheMetricsAndTheCostWrapperButNothingElse(): void
+ {
+ $row = [
+ 'clicks' => 10, 'click_out' => 5, 'ctr' => '50%', 'leads' => 1, 'su_ratio' => '10%',
+ 'payout' => '$4.00', 'epc' => '$0.40', 'cpc' => '$0.10', 'income' => '$4.00',
+ 'cost' => '$1.00', 'cost_wrapper' => '($1.00)', 'net' => '$3.00', 'roi' => '300%',
+ 'keyword' => 'shoes',
+ ];
+ $masked = CampaignDataMask::apply($row);
+
+ foreach (['clicks', 'click_out', 'leads', 'income', 'cost', 'cost_wrapper', 'net'] as $k) {
+ self::assertSame('?', $masked[$k], $k);
+ }
+ foreach (['ctr', 'su_ratio', 'payout', 'epc', 'cpc', 'roi', 'keyword'] as $k) {
+ self::assertSame($row[$k], $masked[$k], "$k must stay visible");
+ }
+ self::assertSame(array_keys($row), array_keys($masked), 'apply() must not add or drop keys');
+ }
+
+ public function testApplyWithAPrefixTouchesOnlyThatPrefix(): void
+ {
+ $row = ['clicks' => 10, 'total_clicks' => 100, 'total_net' => '$9', 'total_cost_wrapper' => '($1)', 'rotator_clicks' => 7];
+ $masked = CampaignDataMask::apply($row, 'total_');
+
+ self::assertSame(['clicks' => 10, 'total_clicks' => '?', 'total_net' => '?', 'total_cost_wrapper' => '?', 'rotator_clicks' => 7], $masked);
+ self::assertSame('?', CampaignDataMask::apply($row, 'rotator_')['rotator_clicks']);
+ }
+
+ public function testApplyDeepMasksNestedRowsAndTheTotalsNode(): void
+ {
+ // The variable report's shape: network rows carrying nested variable
+ // rows carrying value rows, then a final node with only total_* keys.
+ $data = [
+ [
+ 0 => ['ppc_network_name' => 'Google', 'clicks' => 50, 'net' => '$5'],
+ 'variables' => [
+ [0 => ['variable_name' => 'c1', 'clicks' => 20], 'values' => [['variable_value' => 'a', 'clicks' => 20, 'income' => '$2', 'roi' => '10%']]],
+ ],
+ ],
+ ['total_clicks' => 50, 'total_leads' => 2, 'total_income' => '$5', 'total_cost' => '$1', 'total_net' => '$4', 'total_roi' => '400%'],
+ ];
+ $masked = CampaignDataMask::applyDeep($data);
+
+ self::assertSame('?', $masked[0][0]['clicks']);
+ self::assertSame('?', $masked[0][0]['net']);
+ self::assertSame('Google', $masked[0][0]['ppc_network_name']);
+ self::assertSame('?', $masked[0]['variables'][0][0]['clicks']);
+ self::assertSame('?', $masked[0]['variables'][0]['values'][0]['clicks']);
+ self::assertSame('?', $masked[0]['variables'][0]['values'][0]['income']);
+ self::assertSame('10%', $masked[0]['variables'][0]['values'][0]['roi']);
+ // The whole point: the totals line.
+ foreach (['total_clicks', 'total_leads', 'total_income', 'total_cost', 'total_net'] as $k) {
+ self::assertSame('?', $masked[1][$k], $k);
+ }
+ self::assertSame('400%', $masked[1]['total_roi']);
+ }
+
+ // ------------------------------------------------------------------
+ // Tree shape
+
+ public function testThePermissionIsNegatedOnlyInsideTheClass(): void
+ {
+ // `!$userObj->hasPermission('access_to_campaign_data')` is the masking
+ // decision. Positive gates (account_overview.php shows a section only
+ // to users WITH the permission) are a different question and allowed.
+ $offenders = [];
+ foreach (SourceScan::phpFiles() as $path => $source) {
+ if ($path === '202-config/Report/CampaignDataMask.php') {
+ continue;
+ }
+ if (SourceScan::countMatches('/!\s*\$\w+->hasPermission\(\s*[\'"]access_to_campaign_data[\'"]/', $source, $path) > 0) {
+ $offenders[] = $path;
+ }
+ }
+
+ self::assertSame([], $offenders, 'These files restate the masking predicate instead of calling '
+ . 'CampaignDataMask::hidden(); the copies drifted before (publisher exemption, null guard). '
+ . "Found in:\n " . implode("\n ", $offenders));
+ }
+
+ public function testNoFileMasksAMetricByHand(): void
+ {
+ // `$x['net'] = '?'`, `$x['total_net'] = '?'`, `$x['rotator_cost_wrapper'] = '?'`:
+ // every hand-written mask is a place the key can disagree with the
+ // template. All of them go through CampaignDataMask::apply().
+ $keys = [...CampaignDataMask::METRICS, CampaignDataMask::COST_WRAPPER];
+ $pattern = "/\\\$\\w+\\[['\"](?:\\w+_)?(?:" . implode('|', array_map('preg_quote', $keys)) . ")['\"]\\]\\s*=\\s*'\\?'/";
+
+ $offenders = [];
+ foreach (SourceScan::phpFiles() as $path => $source) {
+ $n = SourceScan::countMatches($pattern, $source, $path, $m);
+ if ($n > 0) {
+ $offenders[$path] = $m[0];
+ }
+ }
+
+ self::assertSame([], $offenders, "These files mask a metric by hand instead of through CampaignDataMask::apply():\n"
+ . implode("\n", array_map(
+ static fn(string $f, array $hits): string => " $f: " . implode(', ', $hits),
+ array_keys($offenders),
+ $offenders
+ )));
+ }
+
+ public function testTotalsTemplatesInTheDataEnginePrintOnlyPrefixedMetrics(): void
+ {
+ // Totals rows are masked with the 'total_' prefix; a bare $x['net'] in
+ // one of those templates would render the real figure.
+ $source = SourceScan::phpFiles()['202-config/class-dataengine.php'];
+ $lines = preg_split('/\R/', $source) ?: [];
+ $inTotals = false;
+ $offenders = [];
+ foreach ($lines as $i => $line) {
+ if (str_contains($line, 'id="totals"')) {
+ $inTotals = true;
+ }
+ if ($inTotals) {
+ if (preg_match_all("/\\\$\\w+\\['(\\w+)'\\]/", $line, $m) > 0) {
+ foreach ($m[1] as $key) {
+ if (in_array($key, CampaignDataMask::METRICS, true) || $key === CampaignDataMask::COST_WRAPPER) {
+ $offenders[] = ($i + 1) . ": $key";
+ }
+ }
+ }
+ if (str_contains($line, '')) {
+ $inTotals = false;
+ }
+ }
+ }
+ self::assertFalse($inTotals, 'a totals template was never closed with ');
+ self::assertSame([], $offenders, 'Totals templates print an unprefixed metric: ' . implode('; ', $offenders));
+ }
+
+ public function testTheVariableReportMasksThroughApplyDeep(): void
+ {
+ $source = SourceScan::phpFiles()['202-config/class-dataengine.php'];
+ self::assertSame(1, preg_match('/private function maskVariableData\(.*?\n \}/s', $source, $body));
+ self::assertStringContainsString('CampaignDataMask::applyDeep(', $body[0]);
+ self::assertStringContainsString('CampaignDataMask::hidden()', $body[0]);
+ }
+}
diff --git a/tests/Rotator/MysqlRotatorRepositoryTest.php b/tests/Rotator/MysqlRotatorRepositoryTest.php
index 315e1c0c..80d94792 100644
--- a/tests/Rotator/MysqlRotatorRepositoryTest.php
+++ b/tests/Rotator/MysqlRotatorRepositoryTest.php
@@ -47,6 +47,12 @@ public function testDeleteChecksOwnershipBeforeCascadeQueries(): void
public function testUpdateRuleScopesUpdateToRotatorId(): void
{
$write = new FakeMysqliConnection();
+ // The rule must resolve to the requested rotator for the ownership
+ // pre-check to pass.
+ $write->whenQueryContainsReturnRows(
+ 'SELECT rotator_id FROM 202_rotator_rules',
+ [['rotator_id' => 8]]
+ );
$conn = new Connection($write);
$repo = new MysqlRotatorRepository($conn);
@@ -58,4 +64,105 @@ public function testUpdateRuleScopesUpdateToRotatorId(): void
self::assertSame('sii', $updates[0]->boundTypes);
self::assertSame(['Updated', 5, 8], $updates[0]->boundValues);
}
+
+ public function testUpdateRuleRejectsRuleBelongingToAnotherRotator(): void
+ {
+ $write = new FakeMysqliConnection();
+ // Rule 5 actually belongs to rotator 99, not the requested 8.
+ $write->whenQueryContainsReturnRows(
+ 'SELECT rotator_id FROM 202_rotator_rules',
+ [['rotator_id' => 99]]
+ );
+ $conn = new Connection($write);
+ $repo = new MysqlRotatorRepository($conn);
+
+ $this->expectException(\RuntimeException::class);
+
+ try {
+ $repo->updateRule(5, 8, ['criteria' => [['type' => 'country', 'statement' => 'is', 'value' => 'US']]]);
+ } finally {
+ // The victim's criteria must never be deleted.
+ self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_criteria'));
+ }
+ }
+
+ public function testDeleteRuleRejectsRuleBelongingToAnotherRotator(): void
+ {
+ $write = new FakeMysqliConnection();
+ $write->whenQueryContainsReturnRows(
+ 'SELECT rotator_id FROM 202_rotator_rules',
+ [['rotator_id' => 99]]
+ );
+ $conn = new Connection($write);
+ $repo = new MysqlRotatorRepository($conn);
+
+ $this->expectException(\RuntimeException::class);
+
+ try {
+ $repo->deleteRule(5, 8);
+ } finally {
+ self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_criteria'));
+ self::assertSame([], $write->statementsContaining('DELETE FROM 202_rotator_rules_redirects'));
+ }
+ }
+
+ /**
+ * Rotators are matched between installs by public_id, so `p202 sync` sends
+ * the source's value. Rejecting it outright made the target assign its own,
+ * so the source never matched the target: every run re-created every rotator
+ * and remapping trackers' rotator_id failed with "unresolvable target foreign
+ * key". A free public_id must therefore be honoured.
+ */
+ public function testCreateHonoursAFreeCallerSuppliedPublicId(): void
+ {
+ $write = new FakeMysqliConnection();
+ // No row comes back for the freeness probe, so 4242424 is available.
+ $write->whenQueryContainsReturnRows('SELECT id FROM 202_rotators WHERE public_id = ?', []);
+ $conn = new Connection($write);
+ $repo = new MysqlRotatorRepository($conn);
+
+ $repo->create(7, ['name' => 'Synced', 'public_id' => 4242424]);
+
+ $inserts = $write->statementsContaining('INSERT INTO 202_rotators');
+ self::assertCount(1, $inserts);
+ self::assertSame(4242424, $inserts[0]->boundValues[0]);
+ }
+
+ /**
+ * The hazard the server-side derivation guards against is collision: public_id
+ * is resolved by the unauthenticated redirect with no user scoping and has no
+ * UNIQUE key. A value already in use must never be accepted.
+ */
+ public function testCreateRejectsAnAlreadyTakenPublicIdAndGeneratesInstead(): void
+ {
+ $write = new FakeMysqliConnection();
+ // Every freeness probe reports the candidate as taken, including the
+ // caller's, so create() must fall through to a generated id.
+ $write->whenQueryContainsReturnRows(
+ 'SELECT id FROM 202_rotators WHERE public_id = ?',
+ [['id' => 1]]
+ );
+ $conn = new Connection($write);
+ $repo = new MysqlRotatorRepository($conn);
+
+ $this->expectException(\RuntimeException::class);
+ $repo->create(7, ['name' => 'Colliding', 'public_id' => 4242424]);
+ }
+
+ public function testCreateGeneratesAPublicIdWhenNoneSupplied(): void
+ {
+ $write = new FakeMysqliConnection();
+ $write->whenQueryContainsReturnRows('SELECT id FROM 202_rotators WHERE public_id = ?', []);
+ $conn = new Connection($write);
+ $repo = new MysqlRotatorRepository($conn);
+
+ $repo->create(7, ['name' => 'Fresh']);
+
+ $inserts = $write->statementsContaining('INSERT INTO 202_rotators');
+ self::assertCount(1, $inserts);
+ $generated = $inserts[0]->boundValues[0];
+ self::assertIsInt($generated);
+ self::assertGreaterThanOrEqual(100000, $generated);
+ self::assertLessThanOrEqual(9999999, $generated);
+ }
}
diff --git a/tests/Support/SourceScan.php b/tests/Support/SourceScan.php
new file mode 100644
index 00000000..db60dd30
--- /dev/null
+++ b/tests/Support/SourceScan.php
@@ -0,0 +1,377 @@
+conn()->commit()`, two statements on one line. Working on
+ * token_get_all() output instead of lines makes those shapes ordinary.
+ *
+ * Nothing here does type inference. That is deliberate: PHPStan rules cover
+ * the receivers whose type is known, and these scanners are the complement for
+ * the untyped legacy code where inference has nothing to work with.
+ */
+final class SourceScan
+{
+ /**
+ * Directories never descended into. vendor/node_modules/.git are not ours;
+ * the rest contain no PHP at all and are only skipped for speed --
+ * testPrunedDirectoriesContainNoPhp() in SourceScanTest keeps that true.
+ */
+ public const PRUNED = ['vendor', 'node_modules', '.git', '202-css', '202-img', 'documentation', 'docs', 'go-cli'];
+
+ /** @var array> keyed by includeTests flag */
+ private static array $cache = [];
+
+ public static function repoRoot(): string
+ {
+ return dirname(__DIR__, 2);
+ }
+
+ /**
+ * Every PHP source under the repository, repo-relative path => contents.
+ * tests/ is excluded by default because tests exercise failure shapes on
+ * purpose.
+ *
+ * @return array
+ */
+ public static function phpFiles(bool $includeTests = false): array
+ {
+ $key = $includeTests ? 'with-tests' : 'no-tests';
+ if (isset(self::$cache[$key])) {
+ return self::$cache[$key];
+ }
+
+ $root = self::repoRoot();
+ $pruned = self::PRUNED;
+ if (!$includeTests) {
+ $pruned[] = 'tests';
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveCallbackFilterIterator(
+ new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
+ static fn(\SplFileInfo $file): bool => !in_array($file->getFilename(), $pruned, true)
+ )
+ );
+
+ $files = [];
+ foreach ($iterator as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'php') {
+ continue;
+ }
+ $path = $file->getPathname();
+ $source = file_get_contents($path);
+ if ($source === false) {
+ // Never let an unreadable file scan as an empty one.
+ throw new RuntimeException("Could not read $path");
+ }
+ $files[substr($path, strlen($root) + 1)] = $source;
+ }
+ ksort($files);
+
+ return self::$cache[$key] = $files;
+ }
+
+ /**
+ * preg_match_all() that refuses to report a failed scan as "no matches".
+ * A pattern that exhausts the backtrack limit on a large file returns
+ * false, which a scanner reading `$count > 0` treats as clean.
+ *
+ * @param array|null $matches receives preg_match_all()'s matches
+ */
+ public static function countMatches(string $pattern, string $source, string $file, ?array &$matches = null): int
+ {
+ $hits = preg_match_all($pattern, $source, $matches);
+ if ($hits === false) {
+ throw new RuntimeException("Scanning $file failed: " . preg_last_error_msg());
+ }
+
+ return $hits;
+ }
+
+ /**
+ * Lines on which a call to one of $methods (as `->name(...)` or
+ * `::name(...)`) or one of $functions (as a bare `name(...)`) forms a
+ * whole statement whose result is discarded: nothing between the previous
+ * statement boundary and the call but the receiver expression, and a `;`
+ * straight after the closing parenthesis.
+ *
+ * `$x = $db->commit();`, `if (!$db->commit())`, `return $db->commit();`
+ * and `$ok && $db->commit()` are all "used" and not reported. The receiver
+ * may be anything: `$db`, `$this->db`, `self::$db`, `$conns['w']`,
+ * `$this->conn()->getWrite()`.
+ *
+ * $zeroArgsOnly restricts to calls with an empty argument list. The
+ * execute() scanner needs it: the checked wrappers are also called
+ * `execute` (Connection::execute($stmt), StatementHelpers::execute($stmt,
+ * $message)) and are told apart from a raw `$stmt->execute()` only by
+ * taking arguments.
+ *
+ * @param list $methods
+ * @param list $functions
+ * @return list 1-based line numbers
+ */
+ public static function uncheckedCallStatements(string $source, array $methods, array $functions = [], bool $zeroArgsOnly = false): array
+ {
+ $tokens = self::significantTokens($source);
+ $count = count($tokens);
+ $lines = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $tok = $tokens[$i];
+ if (!is_array($tok) || $tok[0] !== T_STRING) {
+ continue;
+ }
+ $name = $tok[1];
+ $prev = $i > 0 ? $tokens[$i - 1] : null;
+ $isMethod = in_array($name, $methods, true)
+ && is_array($prev)
+ && in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON], true);
+ $isFunction = in_array($name, $functions, true)
+ && !(is_array($prev) && in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST], true))
+ && $prev !== '\\' && !(is_array($prev) && $prev[0] === T_NS_SEPARATOR);
+ if (!$isMethod && !$isFunction) {
+ continue;
+ }
+ // Must be a call: next token is "(".
+ if (($tokens[$i + 1] ?? null) !== '(') {
+ continue;
+ }
+ $close = self::matchingParen($tokens, $i + 1);
+ if ($close === null || ($tokens[$close + 1] ?? null) !== ';') {
+ continue;
+ }
+ if ($zeroArgsOnly && $close !== $i + 2) {
+ continue;
+ }
+ // Walk back to the statement boundary and make sure the prefix is
+ // only a receiver expression.
+ $start = $i;
+ while ($start > 0 && !self::isStatementBoundary($tokens[$start - 1])) {
+ $start--;
+ }
+ if (!self::isPureReceiver(array_slice($tokens, $start, $i - $start))) {
+ continue;
+ }
+ $lines[] = $tok[2];
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Lines on which `$var->close()` is called after `$var` was handed to one
+ * of $closingHelpers (methods that close the statement themselves) in the
+ * same function body, with no reassignment of `$var` in between.
+ *
+ * @param list $closingHelpers
+ * @return list 1-based line numbers of the redundant close()
+ */
+ public static function closesAfterClosingHelper(string $source, array $closingHelpers): array
+ {
+ $tokens = self::significantTokens($source);
+ $count = count($tokens);
+ $functionEnds = self::functionBodyEnds($tokens);
+ $lines = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $tok = $tokens[$i];
+ if (!is_array($tok) || $tok[0] !== T_STRING || !in_array($tok[1], $closingHelpers, true)) {
+ continue;
+ }
+ $prev = $tokens[$i - 1] ?? null;
+ if (!is_array($prev) || !in_array($prev[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON], true)) {
+ continue;
+ }
+ // helper( $var ...
+ $arg = $tokens[$i + 2] ?? null;
+ if (($tokens[$i + 1] ?? null) !== '(' || !is_array($arg) || $arg[0] !== T_VARIABLE) {
+ continue;
+ }
+ $var = $arg[1];
+ $end = $functionEnds[$i] ?? $count - 1;
+
+ for ($j = $i + 3; $j <= $end; $j++) {
+ $t = $tokens[$j];
+ if (!is_array($t) || $t[0] !== T_VARIABLE || $t[1] !== $var) {
+ continue;
+ }
+ $next = $tokens[$j + 1] ?? null;
+ if ($next === '=') {
+ break; // reassigned: a fresh statement from here on
+ }
+ if (is_array($next) && in_array($next[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR], true)) {
+ $m = $tokens[$j + 2] ?? null;
+ if (is_array($m) && $m[0] === T_STRING && $m[1] === 'close' && ($tokens[$j + 3] ?? null) === '(') {
+ $lines[] = $m[2];
+ }
+ }
+ }
+ }
+
+ return array_values(array_unique($lines));
+ }
+
+ // -------------------------------------------------------------------
+
+ /**
+ * token_get_all() minus whitespace and comments, so adjacency checks mean
+ * "next meaningful token". Single-char tokens stay strings.
+ *
+ * @return list
+ */
+ private static function significantTokens(string $source): array
+ {
+ $out = [];
+ foreach (token_get_all($source) as $t) {
+ if (is_array($t) && in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML, T_OPEN_TAG, T_CLOSE_TAG], true)) {
+ continue;
+ }
+ $out[] = $t;
+ }
+
+ return $out;
+ }
+
+ /** @param list $tokens */
+ private static function matchingParen(array $tokens, int $open): ?int
+ {
+ $depth = 0;
+ $n = count($tokens);
+ for ($i = $open; $i < $n; $i++) {
+ $t = $tokens[$i];
+ if ($t === '(') {
+ $depth++;
+ } elseif ($t === ')') {
+ $depth--;
+ if ($depth === 0) {
+ return $i;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /** @param string|array{0:int,1:string,2:int} $t */
+ private static function isStatementBoundary(string|array $t): bool
+ {
+ if (is_string($t)) {
+ return in_array($t, [';', '{', '}', ':'], true);
+ }
+
+ return in_array($t[0], [T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO], true);
+ }
+
+ /**
+ * True when the tokens form nothing but a receiver expression: variables,
+ * property/static access, identifiers, parenthesised calls, array offsets
+ * and literals. Any operator or keyword means the call's value is used.
+ *
+ * @param list $tokens
+ */
+ private static function isPureReceiver(array $tokens): bool
+ {
+ // `if ($x) $db->commit();` -- a brace-less control clause is not part
+ // of the receiver, and the value is just as discarded. Strip it.
+ while ($tokens !== [] && is_array($tokens[0])
+ && in_array($tokens[0][0], [T_IF, T_ELSEIF, T_WHILE, T_FOR, T_FOREACH, T_ELSE], true)) {
+ $head = array_shift($tokens);
+ if ($head[0] !== T_ELSE) {
+ $close = self::matchingParen($tokens, 0);
+ if ($close === null) {
+ return false;
+ }
+ $tokens = array_slice($tokens, $close + 1);
+ }
+ }
+
+ foreach ($tokens as $t) {
+ if (is_string($t)) {
+ if (!in_array($t, ['(', ')', '[', ']', ',', '\\'], true)) {
+ return false;
+ }
+ continue;
+ }
+ if (!in_array($t[0], [
+ T_VARIABLE, T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR,
+ T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON,
+ T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_STATIC,
+ ], true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * For every token index inside a function body, the index of the `}`
+ * that closes that body (innermost function wins).
+ *
+ * @param list $tokens
+ * @return array
+ */
+ private static function functionBodyEnds(array $tokens): array
+ {
+ $n = count($tokens);
+ $ends = [];
+ // Pass 1: find each function body's "{" and its matching "}".
+ $bodies = [];
+ for ($i = 0; $i < $n; $i++) {
+ $t = $tokens[$i];
+ if (!is_array($t) || $t[0] !== T_FUNCTION) {
+ continue;
+ }
+ // Skip to the body "{" (past the parameter list and return type);
+ // an abstract/interface method ends in ";" and has no body.
+ $j = $i + 1;
+ while ($j < $n && $tokens[$j] !== '{' && $tokens[$j] !== ';') {
+ if ($tokens[$j] === '(') {
+ $j = self::matchingParen($tokens, $j) ?? $n;
+ }
+ $j++;
+ }
+ if ($j >= $n || $tokens[$j] !== '{') {
+ continue;
+ }
+ $depth = 0;
+ for ($k = $j; $k < $n; $k++) {
+ if ($tokens[$k] === '{') {
+ $depth++;
+ } elseif ($tokens[$k] === '}') {
+ $depth--;
+ if ($depth === 0) {
+ $bodies[] = [$j, $k];
+ break;
+ }
+ }
+ }
+ }
+ // Pass 2: innermost body wins -- later (nested) bodies overwrite.
+ usort($bodies, static fn(array $a, array $b): int => ($b[1] - $b[0]) <=> ($a[1] - $a[0]));
+ foreach ($bodies as [$open, $close]) {
+ for ($i = $open; $i <= $close; $i++) {
+ $ends[$i] = $close;
+ }
+ }
+
+ return $ends;
+ }
+}
diff --git a/tests/Support/SourceScanTest.php b/tests/Support/SourceScanTest.php
new file mode 100644
index 00000000..2cc74c5b
--- /dev/null
+++ b/tests/Support/SourceScanTest.php
@@ -0,0 +1,144 @@
+begin_transaction();\n" // 2
+ . "\$this->db->begin_transaction();\n" // 3
+ . "\$db->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);\n" // 4 arguments
+ . "\$db->commit(0, \"name\");\n" // 5 arguments
+ . "\$conn->begin_transaction(); // start\n" // 6 trailing comment
+ . "\$this->getDb()->begin_transaction();\n" // 7 chained receiver
+ . "DB::getInstance()->getConnection()->commit();\n" // 8 static chain
+ . "self::\$db->commit();\n" // 9 static property
+ . "\$this->connections['w']->commit();\n" // 10 array element
+ . "\$db->begin_transaction();\$db->commit();\n" // 11 two per line
+ . "\$this->db->commit();\r\n" // 12 CRLF
+ . "\$db\n ->commit(\n );\n" // 14 multi-line (name on 14)
+ . "mysqli_begin_transaction(\$db);\n" // 16 procedural
+ . "mysqli_commit(\$db);\n" // 17
+ . "\$db->autocommit(false);\n" // 18
+ . "\$db->commit() ;\n" // 19 space before ;
+ . "if (\$x) \$db->commit();\n" // 20 brace-less if
+ . "else \$db->commit();\n"; // 21 brace-less else
+
+ self::assertSame(
+ [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 11, 12, 14, 16, 17, 18, 19, 20, 21],
+ SourceScan::uncheckedCallStatements($source, self::TX_METHODS, self::TX_FUNCTIONS)
+ );
+ }
+
+ public function testCheckedCallsAreNotReported(): void
+ {
+ $source = "begin_transaction()) { throw new E(); }\n"
+ . "\$ok = \$db->begin_transaction();\n"
+ . "return \$db->commit();\n"
+ . "\$ok && \$db->commit();\n"
+ . "\$r = \$a ? \$db->commit() : false;\n"
+ . "if (\$x) \$ok = \$db->commit();\n"
+ . "if (!mysqli_commit(\$db)) { return; }\n"
+ . "\$db->rollback();\n" // not in the list
+ . "\$stmt->execute();\n" // not in the list
+ . "\$x->committed();\n" // different name
+ . "\$c = new Commit(); commit();\n"; // bare function not in the function list
+
+ self::assertSame([], SourceScan::uncheckedCallStatements($source, self::TX_METHODS, self::TX_FUNCTIONS));
+ }
+
+ public function testZeroArgsOnlyTellsRawExecuteFromTheCheckedWrappers(): void
+ {
+ $source = "execute();\n" // 2: raw
+ . "\$this->conn->execute(\$stmt);\n" // wrapper: has an argument
+ . "\$this->execute(\$stmt, 'Create failed');\n" // wrapper
+ . "\$stmt->execute(); // c\n"; // 5: raw
+
+ self::assertSame([2, 5], SourceScan::uncheckedCallStatements($source, ['execute'], [], true));
+ self::assertSame([2, 3, 4, 5], SourceScan::uncheckedCallStatements($source, ['execute']));
+ }
+
+ public function testEveryDoubleCloseShapeIsFound(): void
+ {
+ $source = "conn->fetchOne(\$stmt) ?? [];\n \$stmt->close(); }\n" // 4
+ . " function b() { if (\$this->conn->fetchOne(\$stmt) === null) { return; }\n \$stmt->close(); }\n" // 6
+ . " function c() { \$rows = \$this->conn->fetchAll(\$stmt);\n /* c */\n \$stmt->close(); }\n" // 9
+ . " function d() { \$id = \$this->conn->executeInsert(\$stmt);\n # note\n \$stmt->close(); }\n" // 12
+ . " function e() { \$n = \$this->conn->executeUpdate(\$stmt);\n if (\$x) {}\n \$stmt->close(); }\n" // 15
+ . " function f() { if (\$x) { \$this->conn->fetchOne(\$stmt); }\n \$stmt->close(); }\n" // 17 (double on the if path)
+ . "}\n";
+
+ self::assertSame(
+ [4, 6, 9, 12, 15, 17],
+ SourceScan::closesAfterClosingHelper($source, ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate'])
+ );
+ }
+
+ public function testLegitimateClosesAreNotReported(): void
+ {
+ $source = "conn->fetchOne(\$stmt);\n \$stmt = \$this->conn->prepareRead('x');\n \$stmt->close(); }\n" // reassigned
+ . " function g() { \$row = \$this->conn->fetchOne(\$stmt); }\n function h() { \$stmt->close(); }\n" // other function
+ . " function i() { \$row = \$this->conn->fetchOne(\$stmt); \$other->close(); }\n" // other variable
+ . " function j() { \$this->conn->execute(\$stmt); \$stmt->close(); }\n" // execute() does not close
+ . "}\n";
+
+ self::assertSame([], SourceScan::closesAfterClosingHelper($source, ['fetchOne', 'fetchAll', 'executeInsert', 'executeUpdate']));
+ }
+
+ public function testCountMatchesRefusesToReportAFailedScanAsClean(): void
+ {
+ // A nested quantifier over a long input exhausts the backtrack limit;
+ // preg_match_all() returns false, which must not read as zero.
+ $pathological = str_repeat('a', 100000) . '!';
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Scanning x.php failed');
+ SourceScan::countMatches('/^(a+)+$/', $pathological, 'x.php');
+ }
+
+ public function testPrunedDirectoriesContainNoPhp(): void
+ {
+ // PRUNED skips these for speed on the claim that they hold no PHP. If
+ // that stops being true the scanners go blind to whatever lands there,
+ // so the claim is checked rather than assumed.
+ $root = SourceScan::repoRoot();
+ foreach (SourceScan::PRUNED as $dir) {
+ if (in_array($dir, ['vendor', 'node_modules', '.git'], true) || !is_dir("$root/$dir")) {
+ continue;
+ }
+ $php = [];
+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator("$root/$dir", \FilesystemIterator::SKIP_DOTS));
+ foreach ($it as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $php[] = substr($file->getPathname(), strlen($root) + 1);
+ }
+ }
+ self::assertSame([], $php, "$dir/ is pruned from the source scans but now contains PHP; remove it from SourceScan::PRUNED.");
+ }
+ }
+
+ public function testTheWalkFindsTheTreeAndExcludesTestsByDefault(): void
+ {
+ $files = SourceScan::phpFiles();
+ self::assertGreaterThan(400, count($files));
+ self::assertArrayHasKey('api/v3/Support/StatementHelpers.php', $files);
+ self::assertArrayNotHasKey('tests/Support/SourceScan.php', $files);
+ self::assertArrayHasKey('tests/Support/SourceScan.php', SourceScan::phpFiles(includeTests: true));
+ }
+}
diff --git a/tests/Validation/OutboundUrlGuardTest.php b/tests/Validation/OutboundUrlGuardTest.php
new file mode 100644
index 00000000..a08f3fd5
--- /dev/null
+++ b/tests/Validation/OutboundUrlGuardTest.php
@@ -0,0 +1,154 @@
+expectException(OutboundUrlException::class);
+ $this->expectExceptionMessageMatches('/' . preg_quote($expectedFragment, '/') . '/');
+ OutboundUrlGuard::assertWellFormed($url, 'webhook_url');
+ }
+
+ public function testWellFormedAcceptsAPublicLiteralAndAHostnameWithoutResolving(): void
+ {
+ OutboundUrlGuard::assertWellFormed('https://203.0.113.10/hook');
+ // A hostname is not resolved at the write boundary: the point is that a
+ // resolver stall cannot block the request, and the cron re-checks anyway.
+ OutboundUrlGuard::assertWellFormed('https://this-host-must-not-be-looked-up.invalid/hook');
+ $this->addToAssertionCount(2);
+ }
+
+ public function testExceptionIsARuntimeExceptionForExistingCatchSites(): void
+ {
+ self::assertInstanceOf(\RuntimeException::class, new OutboundUrlException('x'));
+ }
+
+ // ---- dispatch --------------------------------------------------------
+
+ /**
+ * @dataProvider rejectedUrls
+ */
+ public function testAllowedRejectsTheSameUrls(string $url, string $expectedFragment): void
+ {
+ $this->expectException(OutboundUrlException::class);
+ $this->expectExceptionMessageMatches('/' . preg_quote($expectedFragment, '/') . '/');
+ OutboundUrlGuard::assertAllowed($url, 'webhook_url');
+ }
+
+ public function testLiteralHostIsReturnedForPinning(): void
+ {
+ self::assertSame(['203.0.113.10'], OutboundUrlGuard::assertAllowed('https://203.0.113.10/hook'));
+ }
+
+ /** @return array */
+ public static function rejectedUrls(): array
+ {
+ return [
+ 'cleartext' => ['http://203.0.113.10/hook', 'valid https:// URL'],
+ 'no host' => ['https:///hook', 'valid https:// URL'],
+ 'not a url' => ['not a url', 'valid https:// URL'],
+ 'disallowed port' => ['https://203.0.113.10:9000/hook', 'port must be one of'],
+ 'loopback literal' => ['https://127.0.0.1/hook', 'private or reserved'],
+ 'link local' => ['https://169.254.169.254/hook', 'private or reserved'],
+ 'private literal' => ['https://10.0.0.5/hook', 'private or reserved'],
+ 'cgnat literal' => ['https://100.64.0.1/hook', '100.64.0.0/10'],
+ 'benchmark range' => ['https://198.18.0.1/hook', '198.18.0.0/15'],
+ 'multicast' => ['https://224.0.0.1/hook', '224.0.0.0/4'],
+ ];
+ }
+
+ // ---- pinning ---------------------------------------------------------
+
+ public function testResolveEntryPrefersIpv4(): void
+ {
+ self::assertSame(
+ 'example.com:443:203.0.113.10',
+ OutboundUrlGuard::curlResolveEntry('https://example.com/hook', ['2001:db8::1', '203.0.113.10'])
+ );
+ }
+
+ public function testResolveEntryBracketsIpv6WhenThereIsNoIpv4(): void
+ {
+ // Unbracketed, "example.com:443:2001:db8::1" has more colons than curl's
+ // HOST:PORT:ADDRESS grammar allows; curl rejects the entry and resolves
+ // the host itself, quietly reopening the DNS-rebinding hole.
+ self::assertSame(
+ 'example.com:443:[2001:db8::1]',
+ OutboundUrlGuard::curlResolveEntry('https://example.com/hook', ['2001:db8::1'])
+ );
+ }
+
+ public function testResolveEntryHonoursAnExplicitPort(): void
+ {
+ self::assertSame(
+ 'example.com:8443:203.0.113.10',
+ OutboundUrlGuard::curlResolveEntry('https://example.com:8443/hook', ['203.0.113.10'])
+ );
+ }
+
+ public function testNothingToPinIsAnErrorNotAnUnpinnedSend(): void
+ {
+ // The old contract returned null here and both crons then sent
+ // unpinned -- a fail-open shape with the pinning code still present.
+ $this->expectException(OutboundUrlException::class);
+ $this->expectExceptionMessage('Refusing to send unpinned');
+ OutboundUrlGuard::curlResolveEntry('https://example.com/hook', []);
+ }
+
+ public function testCurlOptionsCarryEveryHardeningSetting(): void
+ {
+ $opts = OutboundUrlGuard::curlOptions('https://example.com/hook', ['203.0.113.10']);
+
+ self::assertSame(['example.com:443:203.0.113.10'], $opts[CURLOPT_RESOLVE]);
+ self::assertFalse($opts[CURLOPT_FOLLOWLOCATION]);
+ self::assertSame(0, $opts[CURLOPT_MAXREDIRS]);
+ self::assertSame(CURLPROTO_HTTPS, $opts[CURLOPT_PROTOCOLS]);
+ self::assertSame(CURLPROTO_HTTPS, $opts[CURLOPT_REDIR_PROTOCOLS]);
+ self::assertTrue($opts[CURLOPT_SSL_VERIFYPEER]);
+ self::assertSame(2, $opts[CURLOPT_SSL_VERIFYHOST]);
+ self::assertGreaterThan(0, $opts[CURLOPT_CONNECTTIMEOUT]);
+ self::assertGreaterThan(0, $opts[CURLOPT_TIMEOUT]);
+ }
+
+ public function testEveryDispatcherUsesTheSharedCurlOptions(): void
+ {
+ // The hardening set exists once so no dispatcher can drop an entry. A
+ // new cron that posts to a user-supplied URL must go through it too.
+ foreach (\Tests\Support\SourceScan::phpFiles() as $path => $source) {
+ if (!str_contains($source, 'CURLOPT_RESOLVE') || $path === '202-config/Validation/OutboundUrlGuard.php') {
+ continue;
+ }
+ self::fail("$path sets CURLOPT_RESOLVE itself; use OutboundUrlGuard::curlOptions() so the pin and the rest of the hardening cannot diverge.");
+ }
+ foreach (['202-cronjobs/attribution-export.php', '202-cronjobs/ltv_webhooks.php'] as $cron) {
+ self::assertStringContainsString(
+ 'OutboundUrlGuard::curlOptions(',
+ \Tests\Support\SourceScan::phpFiles()[$cron],
+ "$cron must apply OutboundUrlGuard::curlOptions()"
+ );
+ }
+ }
+}
diff --git a/tracking202/Report/Json/FlatReportPayloadBuilder.php b/tracking202/Report/Json/FlatReportPayloadBuilder.php
index 3273b2a7..11b83a78 100644
--- a/tracking202/Report/Json/FlatReportPayloadBuilder.php
+++ b/tracking202/Report/Json/FlatReportPayloadBuilder.php
@@ -4,6 +4,7 @@
namespace Tracking202\Report\Json;
+use Prosper202\Report\CampaignDataMask;
use UserPrefs;
final class FlatReportPayloadBuilder
@@ -402,13 +403,10 @@ private static function buildFlaggedLocationPayload(string $name, string $countr
private static function campaignDataRestricted(): bool
{
- global $userObj;
-
- return (bool) (
- $userObj
- && !$userObj->hasPermission('access_to_campaign_data')
- && empty($_SESSION['publisher'])
- );
+ // One decision for every report surface; see CampaignDataMask. The
+ // metric cells above are still built here because this payload wraps
+ // each value in a {display, tone} cell rather than a raw row.
+ return CampaignDataMask::hidden();
}
/**
diff --git a/tracking202/ajax/generate_tracking_link.php b/tracking202/ajax/generate_tracking_link.php
index 84324ef0..7a60e6ae 100755
--- a/tracking202/ajax/generate_tracking_link.php
+++ b/tracking202/ajax/generate_tracking_link.php
@@ -133,15 +133,34 @@
WHERE 202_trackers.tracker_id_public = '".$mysql['tracker_id_public']."' AND 202_trackers.user_id = '".$mysql['user_id']."'";
$get_tracker_result = $db->query($get_tracker_sql);
- $get_tracker_row = $get_tracker_result->fetch_assoc();
-
- if ($get_tracker_result->num_rows > 0) {
- $drop_tracker = "DELETE FROM 202_trackers WHERE tracker_id = '".$get_tracker_row['tracker_id']."'";
- $drop_tracker_result = $db->query($drop_tracker);
+ if (!$get_tracker_result) {
+ record_mysql_error($get_tracker_sql);
}
+ $get_tracker_row = $get_tracker_result->fetch_assoc();
}
- $db->begin_transaction();
+ // The transaction opens BEFORE the edit path's DELETE. Editing a tracker is
+ // implemented as delete-then-recreate, and with the DELETE outside the
+ // transaction any failure of the INSERT/UPDATE below (or of begin itself)
+ // destroyed the user's tracker and never replaced it -- the rollback could
+ // not reach a row deleted in autocommit.
+ //
+ // begin_transaction() is checked because under this bootstrap's
+ // mysqli_report(MYSQLI_REPORT_STRICT) a failure returns false rather than
+ // throwing; ignoring it would run everything below in autocommit with the
+ // rollback() calls undoing nothing. record_mysql_error() is declared
+ // `never` -- it logs mysqli_error($db) and exits -- so nothing follows it.
+ if (!$db->begin_transaction()) {
+ record_mysql_error('begin_transaction() for tracker creation');
+ }
+
+ if (isset($get_tracker_result) && $get_tracker_result->num_rows > 0) {
+ $drop_tracker = "DELETE FROM 202_trackers WHERE tracker_id = '".$get_tracker_row['tracker_id']."'";
+ if (!$db->query($drop_tracker)) {
+ $db->rollback();
+ record_mysql_error($drop_tracker);
+ }
+ }
$tracker_sql = "INSERT INTO `202_trackers`
SET `user_id`='".$mysql['user_id']."',
@@ -183,7 +202,16 @@
die('Error setting tracker ID');
}
- $db->commit();
+ if (!$db->commit()) {
+ // Capture the cause before rollback() overwrites mysqli_error($db), and
+ // roll back BEFORE record_mysql_error(): that helper INSERTs into
+ // 202_mysql_errors on this same connection, and a still-open failed
+ // transaction would swallow that row along with everything else.
+ $commitError = $db->error;
+ $db->rollback();
+ error_log('generate_tracking_link: commit failed: ' . $commitError);
+ record_mysql_error('commit() for tracker creation: ' . $commitError);
+ }
$parsed_url = [];
if (!empty($landing_page_row['landing_page_url'])) {
diff --git a/tracking202/ajax/sort_rotator.php b/tracking202/ajax/sort_rotator.php
index b18accc1..4ce30b59 100755
--- a/tracking202/ajax/sort_rotator.php
+++ b/tracking202/ajax/sort_rotator.php
@@ -3,8 +3,16 @@
declare(strict_types=1);
include_once(substr(__DIR__, 0, -17) . '/202-config/connect.php');
+use Prosper202\Report\CampaignDataMask;
+
AUTH::require_user();
+// Decided once for the whole screen. This is the same predicate every other
+// report surface uses -- including the publisher exemption, which this file
+// used to skip, and the null-$userObj guard -- and the same metric list
+// (click_out was missing here).
+$campaignDataHidden = CampaignDataMask::hidden();
+
//set the timezone for the user, for entering their dates.
AUTH::set_timezone($_SESSION['user_timezone']);
@@ -161,12 +169,8 @@
$html['rotator_roi'] = htmlentities($roi . '%', ENT_QUOTES, 'UTF-8');
$html['rotator_cost_wrapper'] = '(' . $html['rotator_cost'] . ')';
- if (!$userObj->hasPermission("access_to_campaign_data")) {
- $html['rotator_clicks'] = '?';
- $html['rotator_leads'] = '?';
- $html['rotator_income'] = '?';
- $html['rotator_cost_wrapper'] = '?';
- $html['rotator_net'] = '?';
+ if ($campaignDataHidden) {
+ $html = CampaignDataMask::apply($html, 'rotator_');
}
?>
@@ -245,12 +249,8 @@
$html['rule_roi'] = htmlentities($rule_roi . '%', ENT_QUOTES, 'UTF-8');
$html['rule_cost_wrapper'] = '(' . $html['rule_cost'] . ')';
- if (!$userObj->hasPermission("access_to_campaign_data")) {
- $html['rule_clicks'] = '?';
- $html['rule_leads'] = '?';
- $html['rule_income'] = '?';
- $html['rule_cost_wrapper'] = '?';
- $html['rule_net'] = '?';
+ if ($campaignDataHidden) {
+ $html = CampaignDataMask::apply($html, 'rule_');
}
?>
@@ -324,12 +324,8 @@
$html['default_cost_wrapper'] = '(' . $html['default_cost'] . ')';
- if (!$userObj->hasPermission("access_to_campaign_data")) {
- $html['default_clicks'] = '?';
- $html['default_leads'] = '?';
- $html['default_income'] = '?';
- $html['default_cost_wrapper'] = '?';
- $html['default_net'] = '?';
+ if ($campaignDataHidden) {
+ $html = CampaignDataMask::apply($html, 'default_');
}
?>
@@ -378,12 +374,8 @@
$html['total_cost_wrapper'] = '(' . $html['total_cost'] . ')';
- if (!$userObj->hasPermission("access_to_campaign_data")) {
- $html['total_clicks'] = '?';
- $html['total_leads'] = '?';
- $html['total_income'] = '?';
- $html['total_cost_wrapper'] = '?';
- $html['total_net'] = '?';
+ if ($campaignDataHidden) {
+ $html = CampaignDataMask::apply($html, 'total_');
}
?>
diff --git a/tracking202/analyze/browser_download.php b/tracking202/analyze/browser_download.php
index 36b89d14..9e5f2071 100755
--- a/tracking202/analyze/browser_download.php
+++ b/tracking202/analyze/browser_download.php
@@ -21,6 +21,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/cities_download.php b/tracking202/analyze/cities_download.php
index bce88ec4..254bf3c1 100755
--- a/tracking202/analyze/cities_download.php
+++ b/tracking202/analyze/cities_download.php
@@ -21,6 +21,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/countries_download.php b/tracking202/analyze/countries_download.php
index 96c2d3f7..be49762c 100755
--- a/tracking202/analyze/countries_download.php
+++ b/tracking202/analyze/countries_download.php
@@ -20,6 +20,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/device_download.php b/tracking202/analyze/device_download.php
index 4a54fd12..e747511c 100755
--- a/tracking202/analyze/device_download.php
+++ b/tracking202/analyze/device_download.php
@@ -20,6 +20,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/ips_download.php b/tracking202/analyze/ips_download.php
index 74bcae3d..fa00e205 100644
--- a/tracking202/analyze/ips_download.php
+++ b/tracking202/analyze/ips_download.php
@@ -20,6 +20,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/isps_download.php b/tracking202/analyze/isps_download.php
index c380b7c9..0e1fd240 100755
--- a/tracking202/analyze/isps_download.php
+++ b/tracking202/analyze/isps_download.php
@@ -21,6 +21,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/keywords_download.php b/tracking202/analyze/keywords_download.php
index 5b07f759..5fea33c5 100755
--- a/tracking202/analyze/keywords_download.php
+++ b/tracking202/analyze/keywords_download.php
@@ -20,6 +20,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/landing_pages_download.php b/tracking202/analyze/landing_pages_download.php
index 42c6ff48..ba687d24 100644
--- a/tracking202/analyze/landing_pages_download.php
+++ b/tracking202/analyze/landing_pages_download.php
@@ -21,6 +21,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=" . $mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/platform_download.php b/tracking202/analyze/platform_download.php
index c2b09061..6f4005cd 100755
--- a/tracking202/analyze/platform_download.php
+++ b/tracking202/analyze/platform_download.php
@@ -20,6 +20,7 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/analyze/regions_download.php b/tracking202/analyze/regions_download.php
index 79e395e3..f91cd889 100755
--- a/tracking202/analyze/regions_download.php
+++ b/tracking202/analyze/regions_download.php
@@ -13,8 +13,8 @@
AUTH::require_user();
$time = grab_timeframe();
-$mysql['to'] = $db->real_escape_string($time['to']);
-$mysql['from'] = $db->real_escape_string($time['from']);
+$mysql['to'] = $db->real_escape_string((string)$time['to']);
+$mysql['from'] = $db->real_escape_string((string)$time['from']);
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
diff --git a/tracking202/analyze/text_ads_download.php b/tracking202/analyze/text_ads_download.php
index c28655fc..750a857c 100644
--- a/tracking202/analyze/text_ads_download.php
+++ b/tracking202/analyze/text_ads_download.php
@@ -13,8 +13,8 @@
AUTH::require_user();
$time = grab_timeframe();
-$mysql['to'] = $db->real_escape_string($time['to']);
-$mysql['from'] = $db->real_escape_string($time['from']);
+$mysql['to'] = $db->real_escape_string((string)$time['to']);
+$mysql['from'] = $db->real_escape_string((string)$time['from']);
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
diff --git a/tracking202/analyze/variables_download.php b/tracking202/analyze/variables_download.php
index 9c08c4ef..c4192e71 100755
--- a/tracking202/analyze/variables_download.php
+++ b/tracking202/analyze/variables_download.php
@@ -13,13 +13,14 @@
AUTH::require_user();
$time = grab_timeframe();
-$mysql['to'] = $db->real_escape_string($time['to']);
-$mysql['from'] = $db->real_escape_string($time['from']);
+$mysql['to'] = $db->real_escape_string((string)$time['to']);
+$mysql['from'] = $db->real_escape_string((string)$time['from']);
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT user_pref_breakdown, user_pref_show, user_cpc_or_cpv FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$breakdown = $user_row['user_pref_breakdown'];
$cpv = ($user_row['user_cpc_or_cpv'] == 'cpv');
diff --git a/tracking202/overview/group_overview_download.php b/tracking202/overview/group_overview_download.php
index db5c0a36..65d5659a 100644
--- a/tracking202/overview/group_overview_download.php
+++ b/tracking202/overview/group_overview_download.php
@@ -10,14 +10,15 @@
//grab the users date range preferences
$time = grab_timeframe();
- $mysql['to'] = $db->real_escape_string($time['to']);
- $mysql['from'] = $db->real_escape_string($time['from']);
+ $mysql['to'] = $db->real_escape_string((string)$time['to']);
+ $mysql['from'] = $db->real_escape_string((string)$time['from']);
//show real or filtered clicks
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$user_sql = "SELECT * FROM 202_users_pref WHERE user_id=".$mysql['user_id'];
$user_result = _mysqli_query($user_sql);
+ if (!$user_result) { record_mysql_error($user_sql); }
$user_row = $user_result->fetch_assoc();
$html['user_pref_group_1'] = htmlentities((string)($user_row['user_pref_group_1'] ?? ''), ENT_QUOTES, 'UTF-8');
@@ -40,7 +41,9 @@
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
- $info_result = _mysqli_query($summary_form->getQuery($mysql['user_id'],$user_row));
+ $info_sql = $summary_form->getQuery($mysql['user_id'],$user_row);
+ $info_result = _mysqli_query($info_sql);
+ if (!$info_result) { record_mysql_error($info_sql); }
while ($row = $info_result->fetch_assoc()) {
$summary_form->addReportData($row);
}
diff --git a/tracking202/redirect/dl.php b/tracking202/redirect/dl.php
index e0d83260..5b636071 100644
--- a/tracking202/redirect/dl.php
+++ b/tracking202/redirect/dl.php
@@ -301,51 +301,51 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
case "bidded":
#try to get the bidded keyword first
if (isset($_GET['OVKEY'])) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVKEY']);
+ $keyword = (string)$_GET['OVKEY'];
} elseif (isset($_GET['t202kw'])) {
- $keyword = $db->real_escape_string((string)$_GET['t202kw']);
+ $keyword = (string)$_GET['t202kw'];
} elseif (isset($_GET['target_passthrough'])) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} else { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)($_GET['keyword'] ?? ''));
+ $keyword = (string)($_GET['keyword'] ?? '');
}
break;
case "searched":
#try to get the searched keyword
if (isset($referer_query['q'])) {
- $keyword = $db->real_escape_string($referer_query['q']);
+ $keyword = $referer_query['q'];
} elseif (isset($_GET['OVRAW'])) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVRAW']);
+ $keyword = (string)$_GET['OVRAW'];
} elseif (isset($_GET['target_passthrough'])) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} elseif (isset($_GET['keyword'])) { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['keyword']);
+ $keyword = (string)$_GET['keyword'];
} elseif (isset($_GET['search_word'])) { //if this is a eniro, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['search_word']);
+ $keyword = (string)$_GET['search_word'];
} elseif (isset($_GET['query'])) { //if this is a naver, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['query']);
+ $keyword = (string)$_GET['query'];
} elseif (isset($_GET['encquery'])) { //if this is a aol, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['encquery']);
+ $keyword = (string)$_GET['encquery'];
} elseif (isset($_GET['terms'])) { //if this is a about.com, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['terms']);
+ $keyword = (string)$_GET['terms'];
} elseif (isset($_GET['rdata'])) { //if this is a viola, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['rdata']);
+ $keyword = (string)$_GET['rdata'];
} elseif (isset($_GET['qs'])) { //if this is a virgilio, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qs']);
+ $keyword = (string)$_GET['qs'];
} elseif (isset($_GET['wd'])) { //if this is a baidu, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['wd']);
+ $keyword = (string)$_GET['wd'];
} elseif (isset($_GET['text'])) { //if this is a yandex, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['text']);
+ $keyword = (string)$_GET['text'];
} elseif (isset($_GET['szukaj'])) { //if this is a wp.pl, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['szukaj']);
+ $keyword = (string)$_GET['szukaj'];
} elseif (isset($_GET['qt'])) { //if this is a O*net, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qt']);
+ $keyword = (string)$_GET['qt'];
} elseif (isset($_GET['k'])) { //if this is a yam, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['k']);
+ $keyword = (string)$_GET['k'];
} elseif (isset($_GET['words'])) { //if this is a Rambler, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['words']);
+ $keyword = (string)$_GET['words'];
} else {
- $keyword = $db->real_escape_string((string)($_GET['t202kw'] ?? ''));
+ $keyword = (string)($_GET['t202kw'] ?? '');
}
break;
}
@@ -366,8 +366,9 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
//Get C1-C4 IDs
for ($i = 1; $i <= 4; $i++) {
$custom = "c" . $i; //create dynamic variable
- $custom_val = $_lGET[$custom] ?? '';
- $custom_val = $db->real_escape_string($custom_val); // get the value
+ // Raw value: findOrCreateCustomVar() binds it as a parameter, so escaping
+ // here would store literal backslashes.
+ $custom_val = (string) ($_lGET[$custom] ?? '');
$custom_val = str_replace('%20', ' ', $custom_val);
$custom_id = $trackingRepo->findOrCreateCustomVar($custom, $custom_val); //get the id
$mysql[$custom . '_id'] = $db->real_escape_string((string)$custom_id); //save it
@@ -381,7 +382,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
$parameters = !empty($tracker_row['parameters']) ? explode(',', (string) $tracker_row['parameters']) : [];
foreach ($parameters as $key => $value) {
- $variable = $db->real_escape_string((string)($_GET[$value] ?? ''));
+ $variable = (string)($_GET[$value] ?? '');
if (isset($variable) && $variable != '') {
$variable = str_replace('%20', ' ', $variable);
@@ -391,7 +392,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
}
//utm_source
-$utm_source = $db->real_escape_string((string)($_GET['utm_source'] ?? ''));
+$utm_source = (string)($_GET['utm_source'] ?? '');
if (isset($utm_source) && $utm_source != '') {
$utm_source = str_replace('%20', ' ', $utm_source);
$utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source');
@@ -401,7 +402,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
$mysql['utm_source_id'] = $db->real_escape_string((string)$utm_source_id);
//utm_medium
-$utm_medium = $db->real_escape_string((string)($_GET['utm_medium'] ?? ''));
+$utm_medium = (string)($_GET['utm_medium'] ?? '');
if (isset($utm_medium) && $utm_medium != '') {
$utm_medium = str_replace('%20', ' ', $utm_medium);
$utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium');
@@ -411,7 +412,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
$mysql['utm_medium_id'] = $db->real_escape_string((string)$utm_medium_id);
//utm_campaign
-$utm_campaign = $db->real_escape_string((string)($_GET['utm_campaign'] ?? ''));
+$utm_campaign = (string)($_GET['utm_campaign'] ?? '');
if (isset($utm_campaign) && $utm_campaign != '') {
$utm_campaign = str_replace('%20', ' ', $utm_campaign);
$utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign');
@@ -421,7 +422,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
$mysql['utm_campaign_id'] = $db->real_escape_string((string)$utm_campaign_id);
//utm_term
-$utm_term = $db->real_escape_string((string)($_GET['utm_term'] ?? ''));
+$utm_term = (string)($_GET['utm_term'] ?? '');
if (isset($utm_term) && $utm_term != '') {
$utm_term = str_replace('%20', ' ', $utm_term);
$utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term');
@@ -431,7 +432,7 @@ function renderErrorPage(int $code, string $title, string $message, string $acce
$mysql['utm_term_id'] = $db->real_escape_string((string)$utm_term_id);
//utm_content
-$utm_content = $db->real_escape_string((string)($_GET['utm_content'] ?? ''));
+$utm_content = (string)($_GET['utm_content'] ?? '');
if (isset($utm_content) && $utm_content != '') {
$utm_content = str_replace('%20', ' ', $utm_content);
$utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content');
diff --git a/tracking202/redirect/off.php b/tracking202/redirect/off.php
index e2447c6f..a59f1e8c 100755
--- a/tracking202/redirect/off.php
+++ b/tracking202/redirect/off.php
@@ -159,7 +159,7 @@
} else {
// cloaking ON, so do a meta REFRESH
- $html['aff_campaign_name'] = $aff_campaign_row['aff_campaign_name'];
+ $html['aff_campaign_name'] = htmlspecialchars((string) $aff_campaign_row['aff_campaign_name'], ENT_QUOTES, 'UTF-8');
?>
@@ -394,10 +394,14 @@
$de = new DataEngine();
$data=($de->setDirtyHour($mysql['click_id']));
+// Assign before the output below: the earlier assignment lives in the other
+// branch, so this path was echoing an undefined key. Escaped like the URL.
+$html['aff_campaign_name'] = htmlspecialchars((string) ($info_row['aff_campaign_name'] ?? ''), ENT_QUOTES, 'UTF-8');
+
if ($cloaking_on == true) {
-
+
// if cloaking is turned on, meta refresh out
-
+
?>
diff --git a/tracking202/redirect/offrtr.php b/tracking202/redirect/offrtr.php
index ab389446..cafe9158 100755
--- a/tracking202/redirect/offrtr.php
+++ b/tracking202/redirect/offrtr.php
@@ -43,7 +43,8 @@
ac.aff_campaign_url_5,
ac.aff_campaign_payout,
ac.aff_campaign_cloaking,
- lp.landing_page_url
+ up.maxmind_isp,
+ lp.landing_page_url
FROM 202_rotators AS rt
LEFT JOIN 202_aff_campaigns AS ac ON ac.aff_campaign_id = rt.default_campaign
LEFT JOIN 202_landing_pages AS lp ON lp.landing_page_id = rt.default_lp
@@ -330,6 +331,9 @@
";
$click_result = $db->query($update_sql) or record_mysql_error($db);
+ // Initialize before the branch so the non-cloaked path doesn't read an
+ // undefined variable at the $cloaking_on checks further down (matches off.php/rtr.php).
+ $cloaking_on = false;
if (($rule_redirect_row['click_cloaking'] == 1) or // if tracker has overrided cloaking on
(($rule_redirect_row['click_cloaking'] == - 1) and ($rule_redirect_row['aff_campaign_cloaking'] == 1)) or ((! isset($rule_redirect_row['click_cloaking'])) and ($rule_redirect_row['aff_campaign_cloaking'] == 1))) // if no tracker but but by default campaign has cloaking on
{
@@ -382,24 +386,24 @@
if ($cloaking_on == true) { ?>
-
+
+ content="1; url=">
@@ -455,6 +459,9 @@
";
$click_result = $db->query($update_sql) or record_mysql_error($db);
+ // Initialize before the branch so the non-cloaked path doesn't read an
+ // undefined variable at the $cloaking_on checks further down (matches off.php/rtr.php).
+ $cloaking_on = false;
if (($click_row['click_cloaking'] == 1) or // if tracker has overrided cloaking on
(($click_row['click_cloaking'] == - 1) and ($rotator_row['aff_campaign_cloaking'] == 1)) or ((! isset($click_row['click_cloaking'])) and ($rotator_row['aff_campaign_cloaking'] == 1))) // if no tracker but but by default campaign has cloaking on
{
@@ -507,24 +514,24 @@
if ($cloaking_on == true) { ?>
-
+
+ content="1; url=">
diff --git a/tracking202/redirect/rtr.php b/tracking202/redirect/rtr.php
index ce37a4c4..c3ab6b24 100755
--- a/tracking202/redirect/rtr.php
+++ b/tracking202/redirect/rtr.php
@@ -595,11 +595,32 @@ function redirect_process($db, $rule, $ppc_account, $cpc, $rotator_id, $GeoData,
ORDER BY 202_clicks.click_id DESC
LIMIT 1";
$click_result1 = $db->query($click_sql1) or record_mysql_error($click_sql1);
- $click_row1 = $click_result1->fetch_assoc();
- $mysql['click_id'] = $db->real_escape_string((string)$click_row1['click_id']);
- $keyword = $db->real_escape_string($keyword);
- $keyword_id = $db->real_escape_string((string)$click_row1['keyword_id']);
- $mysql['keyword_id'] = $db->real_escape_string((string)$keyword_id);
+ $click_row1 = $click_result1 ? $click_result1->fetch_assoc() : null;
+
+ if ($click_row1 && !empty($click_row1['click_id'])) {
+ // Set the bare $click_id too, not just the escaped copy: it is read at
+ // the cloaked click_id_public build and by replaceTrackerPlaceholders for
+ // {clickid}. This reuse path left it undefined, so a cloaked link on the
+ // ?lpr= flow got a malformed public id and an empty {clickid}.
+ $click_id = (int)$click_row1['click_id'];
+ $mysql['click_id'] = $db->real_escape_string((string)$click_id);
+ $keyword = $db->real_escape_string($keyword);
+ $keyword_id = $db->real_escape_string((string)$click_row1['keyword_id']);
+ $mysql['keyword_id'] = $db->real_escape_string((string)$keyword_id);
+ } else {
+ // No prior click matched this IP/user inside the window. Fall back to a
+ // fresh click id instead of writing 202_clicks* rows keyed on an empty
+ // click_id, which produced junk rows that never join back to anything.
+ $click_sql = "INSERT INTO 202_clicks_counter SET click_id=DEFAULT";
+ $click_result = $db->query($click_sql) or record_mysql_error($db);
+ // $click_id (not just $mysql['click_id']) is read later for the cloaked
+ // click_id_public and the {clickid} placeholder, so set both.
+ $click_id = $db->insert_id;
+ $mysql['click_id'] = $db->real_escape_string((string)$click_id);
+ $keyword = $db->real_escape_string($keyword);
+ // Leave $mysql['keyword_id'] as resolved above — this path still has a
+ // real keyword; zeroing it here dropped it from the Keywords report.
+ }
}
else{
//ok we have the main data, now insert this row
diff --git a/tracking202/setup/rotator.php b/tracking202/setup/rotator.php
index 6410fcaa..52d4d3c0 100644
--- a/tracking202/setup/rotator.php
+++ b/tracking202/setup/rotator.php
@@ -227,7 +227,7 @@
}
?>
- Details
+ Details
";
?>
diff --git a/tracking202/static/ipx.php b/tracking202/static/ipx.php
index 2e906e81..5bf7776b 100755
--- a/tracking202/static/ipx.php
+++ b/tracking202/static/ipx.php
@@ -31,8 +31,12 @@
ppc_account_id = '".$tracker_row['ppc_account_id']."',
text_ad_id = '".$tracker_row['text_ad_id']."',
impression_time = '".$time."'";
-$db->query($sql);
-$ipx_id = $db->insert_id;
+// Check the INSERT: on failure insert_id is 0, and writing a p202_ipx=0
+// cookie would bind that meaningless id to the visitor's later click.
+$impression_result = $db->query($sql);
+$ipx_id = $impression_result ? $db->insert_id : 0;
-setcookie("p202_ipx", (string) $ipx_id, ['expires' => $time + (10 * 365 * 24 * 60 * 60), 'path' => '/', 'domain' => (string) $_SERVER['SERVER_NAME']]);
+if ($ipx_id > 0) {
+ setcookie("p202_ipx", (string) $ipx_id, ['expires' => $time + (10 * 365 * 24 * 60 * 60), 'path' => '/', 'domain' => (string) $_SERVER['SERVER_NAME']]);
+}
echo base64_decode("R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==");
diff --git a/tracking202/static/px.php b/tracking202/static/px.php
index c8618228..27d09150 100755
--- a/tracking202/static/px.php
+++ b/tracking202/static/px.php
@@ -16,10 +16,10 @@
//see if it has the cookie, do whatever we can to grab to grab SOMETHING to tie this lead to
-if ($_COOKIE['tracking202subid']) {
+if (isset($_COOKIE['tracking202subid']) && $_COOKIE['tracking202subid']) {
$mysql['click_id'] = $db->real_escape_string($_COOKIE['tracking202subid']);
-
+
} else {
//ok grab the last click from this ip_id
@@ -35,8 +35,10 @@
ORDER BY 202_clicks.click_id DESC
LIMIT 1";
$click_result1 = $db->query($click_sql1) or record_mysql_error($click_sql1);
- $click_row1 = $click_result1->fetch_assoc();
- $mysql['click_id'] = $db->real_escape_string($click_row1['click_id']);
+ $click_row1 = $click_result1 ? $click_result1->fetch_assoc() : null;
+ // No prior click for this IP inside the window — leave click_id empty so the
+ // guard below skips recording, instead of dereferencing a null row.
+ $mysql['click_id'] = $click_row1 ? $db->real_escape_string((string)$click_row1['click_id']) : '';
}
diff --git a/tracking202/static/record_adv.php b/tracking202/static/record_adv.php
index 8a57dafe..e371c835 100755
--- a/tracking202/static/record_adv.php
+++ b/tracking202/static/record_adv.php
@@ -126,51 +126,51 @@
case "bidded":
#try to get the bidded keyword first
if ($_GET['OVKEY']) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVKEY']);
+ $keyword = (string)$_GET['OVKEY'];
} elseif ($_GET['t202kw']) {
- $keyword = $db->real_escape_string((string)$_GET['t202kw']);
+ $keyword = (string)$_GET['t202kw'];
} elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} else { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['keyword']);
+ $keyword = (string)$_GET['keyword'];
}
break;
case "searched":
#try to get the searched keyword
if (!empty($referer_query['q'])) {
- $keyword = $db->real_escape_string($referer_query['q']);
+ $keyword = $referer_query['q'];
} elseif ($_GET['OVRAW']) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVRAW']);
+ $keyword = (string)$_GET['OVRAW'];
} elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} elseif ($_GET['keyword']) { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['keyword']);
+ $keyword = (string)$_GET['keyword'];
} elseif ($_GET['search_word']) { //if this is a eniro, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['search_word']);
+ $keyword = (string)$_GET['search_word'];
} elseif ($_GET['query']) { //if this is a naver, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['query']);
+ $keyword = (string)$_GET['query'];
} elseif ($_GET['encquery']) { //if this is a aol, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['encquery']);
+ $keyword = (string)$_GET['encquery'];
} elseif ($_GET['terms']) { //if this is a about.com, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['terms']);
+ $keyword = (string)$_GET['terms'];
} elseif ($_GET['rdata']) { //if this is a viola, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['rdata']);
+ $keyword = (string)$_GET['rdata'];
} elseif ($_GET['qs']) { //if this is a virgilio, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qs']);
+ $keyword = (string)$_GET['qs'];
} elseif ($_GET['wd']) { //if this is a baidu, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['wd']);
+ $keyword = (string)$_GET['wd'];
} elseif ($_GET['text']) { //if this is a yandex, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['text']);
+ $keyword = (string)$_GET['text'];
} elseif ($_GET['szukaj']) { //if this is a wp.pl, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['szukaj']);
+ $keyword = (string)$_GET['szukaj'];
} elseif ($_GET['qt']) { //if this is a O*net, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qt']);
+ $keyword = (string)$_GET['qt'];
} elseif ($_GET['k']) { //if this is a yam, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['k']);
+ $keyword = (string)$_GET['k'];
} elseif ($_GET['words']) { //if this is a Rambler, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['words']);
+ $keyword = (string)$_GET['words'];
} else {
- $keyword = $db->real_escape_string((string)$_GET['t202kw']);
+ $keyword = (string)$_GET['t202kw'];
}
break;
}
@@ -179,7 +179,7 @@
$t202var = substr((string) $keyword, strpos((string) $keyword, "_") + 1);
if (isset($_GET[$t202var])) {
- $keyword = $db->real_escape_string((string) $_GET[$t202var]);
+ $keyword = (string) $_GET[$t202var];
}
}
@@ -187,22 +187,22 @@
$keyword_id = $trackingRepo->findOrCreateKeyword($keyword);
$mysql['keyword_id'] = $db->real_escape_string((string) $keyword_id);
-$c1 = $db->real_escape_string((string)$_GET['c1']);
+$c1 = (string)$_GET['c1'];
$c1 = str_replace('%20', ' ', $c1);
$c1_id = $trackingRepo->findOrCreateC1($c1);
$mysql['c1_id'] = $db->real_escape_string((string) $c1_id);
-$c2 = $db->real_escape_string((string)$_GET['c2']);
+$c2 = (string)$_GET['c2'];
$c2 = str_replace('%20', ' ', $c2);
$c2_id = $trackingRepo->findOrCreateC2($c2);
$mysql['c2_id'] = $db->real_escape_string((string) $c2_id);
-$c3 = $db->real_escape_string((string)$_GET['c3']);
+$c3 = (string)$_GET['c3'];
$c3 = str_replace('%20', ' ', $c3);
$c3_id = $trackingRepo->findOrCreateC3($c3);
$mysql['c3_id'] = $db->real_escape_string((string) $c3_id);
-$c4 = $db->real_escape_string((string)$_GET['c4']);
+$c4 = (string)$_GET['c4'];
$c4 = str_replace('%20', ' ', $c4);
$c4_id = $trackingRepo->findOrCreateC4($c4);
$mysql['c4_id'] = $db->real_escape_string((string) $c4_id);
@@ -220,7 +220,7 @@
continue;
}
- $variable = $db->real_escape_string((string)$_GET[$value]);
+ $variable = (string)$_GET[$value];
if (isset($variable) && $variable != '') {
$variable = str_replace('%20', ' ', $variable);
@@ -230,7 +230,7 @@
}
//utm_source
-$utm_source = $db->real_escape_string((string)$_GET['utm_source']);
+$utm_source = (string)$_GET['utm_source'];
if (isset($utm_source) && $utm_source != '') {
$utm_source = str_replace('%20', ' ', $utm_source);
$utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source');
@@ -240,7 +240,7 @@
$mysql['utm_source_id'] = $db->real_escape_string((string) $utm_source_id);
//utm_medium
-$utm_medium = $db->real_escape_string((string)$_GET['utm_medium']);
+$utm_medium = (string)$_GET['utm_medium'];
if (isset($utm_medium) && $utm_medium != '') {
$utm_medium = str_replace('%20', ' ', $utm_medium);
$utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium');
@@ -250,7 +250,7 @@
$mysql['utm_medium_id'] = $db->real_escape_string((string) $utm_medium_id);
//utm_campaign
-$utm_campaign = $db->real_escape_string((string)$_GET['utm_campaign']);
+$utm_campaign = (string)$_GET['utm_campaign'];
if (isset($utm_campaign) && $utm_campaign != '') {
$utm_campaign = str_replace('%20', ' ', $utm_campaign);
$utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign');
@@ -260,7 +260,7 @@
$mysql['utm_campaign_id'] = $db->real_escape_string((string) $utm_campaign_id);
//utm_term
-$utm_term = $db->real_escape_string((string)$_GET['utm_term']);
+$utm_term = (string)$_GET['utm_term'];
if (isset($utm_term) && $utm_term != '') {
$utm_term = str_replace('%20', ' ', $utm_term);
$utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term');
@@ -270,7 +270,7 @@
$mysql['utm_term_id'] = $db->real_escape_string((string) $utm_term_id);
//utm_content
-$utm_content = $db->real_escape_string((string)$_GET['utm_content']);
+$utm_content = (string)$_GET['utm_content'];
if (isset($utm_content) && $utm_content != '') {
$utm_content = str_replace('%20', ' ', $utm_content);
$utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content');
diff --git a/tracking202/static/record_simple.php b/tracking202/static/record_simple.php
index d6895700..98ed4677 100755
--- a/tracking202/static/record_simple.php
+++ b/tracking202/static/record_simple.php
@@ -134,52 +134,52 @@
case "bidded":
#try to get the bidded keyword first
if ($_GET['OVKEY']) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVKEY']);
+ $keyword = (string)$_GET['OVKEY'];
} elseif ($_GET['t202kw']) {
- $keyword = $db->real_escape_string((string)$_GET['t202kw']);
+ $keyword = (string)$_GET['t202kw'];
} elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} else { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['keyword']);
+ $keyword = (string)$_GET['keyword'];
}
break;
case "searched":
#try to get the searched keyword
if (!empty($referer_query['q'])) {
- $keyword = $db->real_escape_string($referer_query['q']);
+ $keyword = $referer_query['q'];
} elseif ($_GET['OVRAW']) { //if this is a Y! keyword
- $keyword = $db->real_escape_string((string)$_GET['OVRAW']);
+ $keyword = (string)$_GET['OVRAW'];
} elseif ($_GET['target_passthrough']) { //if this is a mediatraffic! keyword
- $keyword = $db->real_escape_string((string)$_GET['target_passthrough']);
+ $keyword = (string)$_GET['target_passthrough'];
} elseif ($_GET['keyword']) { //if this is a zango, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['keyword']);
+ $keyword = (string)$_GET['keyword'];
} elseif ($_GET['search_word']) { //if this is a eniro, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['search_word']);
+ $keyword = (string)$_GET['search_word'];
} elseif ($_GET['query']) { //if this is a naver, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['query']);
+ $keyword = (string)$_GET['query'];
} elseif ($_GET['encquery']) { //if this is a aol, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['encquery']);
+ $keyword = (string)$_GET['encquery'];
} elseif ($_GET['terms']) { //if this is a about.com, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['terms']);
+ $keyword = (string)$_GET['terms'];
} elseif ($_GET['rdata']) { //if this is a viola, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['rdata']);
+ $keyword = (string)$_GET['rdata'];
} elseif ($_GET['qs']) { //if this is a virgilio, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qs']);
+ $keyword = (string)$_GET['qs'];
} elseif ($_GET['wd']) { //if this is a baidu, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['wd']);
+ $keyword = (string)$_GET['wd'];
} elseif ($_GET['text']) { //if this is a yandex, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['text']);
+ $keyword = (string)$_GET['text'];
} elseif ($_GET['szukaj']) { //if this is a wp.pl, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['szukaj']);
+ $keyword = (string)$_GET['szukaj'];
} elseif ($_GET['qt']) { //if this is a O*net, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['qt']);
+ $keyword = (string)$_GET['qt'];
} elseif ($_GET['k']) { //if this is a yam, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['k']);
+ $keyword = (string)$_GET['k'];
} elseif ($_GET['words']) { //if this is a Rambler, or more keyword
- $keyword = $db->real_escape_string((string)$_GET['words']);
+ $keyword = (string)$_GET['words'];
} else {
- $keyword = $db->real_escape_string((string)$_GET['t202kw']);
+ $keyword = (string)$_GET['t202kw'];
}
break;
}
@@ -188,7 +188,7 @@
$t202var = substr((string) $keyword, strpos((string) $keyword, "_") + 1);
if (isset($_GET[$t202var])) {
- $keyword = $db->real_escape_string((string) $_GET[$t202var]);
+ $keyword = (string) $_GET[$t202var];
}
}
@@ -199,22 +199,22 @@
$mysql['gclid'] = $db->real_escape_string((string)$_GET['gclid']);
-$c1 = $db->real_escape_string((string)$_GET['c1']);
+$c1 = (string)$_GET['c1'];
$c1 = str_replace('%20', ' ', $c1);
$c1_id = $trackingRepo->findOrCreateC1($c1);
$mysql['c1_id'] = $db->real_escape_string((string) $c1_id);
-$c2 = $db->real_escape_string((string)$_GET['c2']);
+$c2 = (string)$_GET['c2'];
$c2 = str_replace('%20', ' ', $c2);
$c2_id = $trackingRepo->findOrCreateC2($c2);
$mysql['c2_id'] = $db->real_escape_string((string) $c2_id);
-$c3 = $db->real_escape_string((string)$_GET['c3']);
+$c3 = (string)$_GET['c3'];
$c3 = str_replace('%20', ' ', $c3);
$c3_id = $trackingRepo->findOrCreateC3($c3);
$mysql['c3_id'] = $db->real_escape_string((string) $c3_id);
-$c4 = $db->real_escape_string((string)$_GET['c4']);
+$c4 = (string)$_GET['c4'];
$c4 = str_replace('%20', ' ', $c4);
$c4_id = $trackingRepo->findOrCreateC4($c4);
$mysql['c4_id'] = $db->real_escape_string((string) $c4_id);
@@ -229,7 +229,7 @@
continue;
}
- $variable = $db->real_escape_string((string)$_GET[$value]);
+ $variable = (string)$_GET[$value];
if (isset($variable) && $variable != '') {
$variable = str_replace('%20', ' ', $variable);
@@ -239,7 +239,7 @@
}
//utm_source
-$utm_source = $db->real_escape_string((string)$_GET['utm_source']);
+$utm_source = (string)$_GET['utm_source'];
if (isset($utm_source) && $utm_source != '') {
$utm_source = str_replace('%20', ' ', $utm_source);
$utm_source_id = $trackingRepo->findOrCreateUtm($utm_source, 'utm_source');
@@ -249,7 +249,7 @@
$mysql['utm_source_id'] = $db->real_escape_string((string) $utm_source_id);
//utm_medium
-$utm_medium = $db->real_escape_string((string)$_GET['utm_medium']);
+$utm_medium = (string)$_GET['utm_medium'];
if (isset($utm_medium) && $utm_medium != '') {
$utm_medium = str_replace('%20', ' ', $utm_medium);
$utm_medium_id = $trackingRepo->findOrCreateUtm($utm_medium, 'utm_medium');
@@ -259,7 +259,7 @@
$mysql['utm_medium_id'] = $db->real_escape_string((string) $utm_medium_id);
//utm_campaign
-$utm_campaign = $db->real_escape_string((string)$_GET['utm_campaign']);
+$utm_campaign = (string)$_GET['utm_campaign'];
if (isset($utm_campaign) && $utm_campaign != '') {
$utm_campaign = str_replace('%20', ' ', $utm_campaign);
$utm_campaign_id = $trackingRepo->findOrCreateUtm($utm_campaign, 'utm_campaign');
@@ -269,7 +269,7 @@
$mysql['utm_campaign_id'] = $db->real_escape_string((string) $utm_campaign_id);
//utm_term
-$utm_term = $db->real_escape_string((string)$_GET['utm_term']);
+$utm_term = (string)$_GET['utm_term'];
if (isset($utm_term) && $utm_term != '') {
$utm_term = str_replace('%20', ' ', $utm_term);
$utm_term_id = $trackingRepo->findOrCreateUtm($utm_term, 'utm_term');
@@ -279,7 +279,7 @@
$mysql['utm_term_id'] = $db->real_escape_string((string) $utm_term_id);
//utm_content
-$utm_content = $db->real_escape_string((string)$_GET['utm_content']);
+$utm_content = (string)$_GET['utm_content'];
if (isset($utm_content) && $utm_content != '') {
$utm_content = str_replace('%20', ' ', $utm_content);
$utm_content_id = $trackingRepo->findOrCreateUtm($utm_content, 'utm_content');
diff --git a/tracking202/update/delete-subids.php b/tracking202/update/delete-subids.php
index 24d36954..98c177a5 100644
--- a/tracking202/update/delete-subids.php
+++ b/tracking202/update/delete-subids.php
@@ -15,6 +15,13 @@
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+ // CSRF check — this endpoint clears lead/filter flags (alters reported
+ // income); gate it on the session token like the setup/ mutations do.
+ if (!hash_equals((string)($_SESSION['token'] ?? ''), (string)($_POST['token'] ?? ''))) {
+ header('location: ' . get_absolute_url() . 'tracking202/update/delete-subids.php');
+ die();
+ }
+
$mysql['user_id'] = $db->real_escape_string((string)$_SESSION['user_id']);
$subids = $_POST['subids'] ?? '';
@@ -22,6 +29,11 @@
$subids = explode("\r", $subids);
$subids = str_replace("\n", '', $subids);
+ // Optimistic before the loop so a mid-loop failure can flip it false. The
+ // previous unconditional `$success = true;` AFTER the loop overwrote every
+ // failure, reporting success even when updates had failed.
+ $success = true;
+
foreach ($subids as $click_id) {
$mysql['click_id'] = $db->real_escape_string($click_id);
@@ -53,10 +65,9 @@
click_id='" . $mysql['click_id'] . "'
AND user_id='" . $mysql['user_id'] . "'
";
- try {
- $update_result = $db->query($update_sql);
- } catch (Exception $e) {
- error_log("Database query failed: " . $e->getMessage());
+ // Return-value check, not try/catch: see the note on the spy update below.
+ if ($db->query($update_sql) === false) {
+ error_log("delete-subids clicks update failed: " . $db->error);
$success = false;
continue;
}
@@ -70,13 +81,20 @@
click_id='" . $mysql['click_id'] . "'
AND user_id='" . $mysql['user_id'] . "'
";
- $update_result = $db->query($update_sql) or die($db->error);
+ // connect.php sets mysqli_report(MYSQLI_REPORT_STRICT) WITHOUT
+ // MYSQLI_REPORT_ERROR, so a failed query() returns false rather than
+ // throwing — check the return value, a catch block would never run.
+ // (Replaces `or die($db->error)`, which leaked the raw MySQL error and
+ // left 202_clicks updated while 202_clicks_spy was not.)
+ if ($db->query($update_sql) === false) {
+ error_log("delete-subids spy update failed: " . $db->error);
+ $success = false;
+ continue;
+ }
$de = new DataEngine();
$de->setDirtyHour($mysql['click_id']);
}
-
- $success = true;
}
//show the template
@@ -111,6 +129,7 @@