From 470dd684eaa57955ef79d231f53a79b3adf1970b Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 08:11:27 -0400 Subject: [PATCH 1/2] test: discover all packaged PHP claim surfaces --- tests/PublicClaimsTest.php | 48 +++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/tests/PublicClaimsTest.php b/tests/PublicClaimsTest.php index 7adf10b..c0f15ba 100644 --- a/tests/PublicClaimsTest.php +++ b/tests/PublicClaimsTest.php @@ -9,20 +9,20 @@ final class PublicClaimsTest extends TestCase { + public function testPublicSurfaceDiscoveryCoversNestedPackageFiles(): void + { + $root = dirname(__DIR__); + $files = $this->publicSurfaceFiles($root); + + self::assertContains('src/RawClient.php', $files); + self::assertContains('src/Http/CurlTransport.php', $files); + self::assertContains('src/Exception/TransportException.php', $files); + } + public function testPublicSurfacesContainNoHighRiskProductClaims(): void { $root = dirname(__DIR__); - $files = [ - 'README.md', - 'CHANGELOG.md', - 'composer.json', - 'src/Client.php', - 'src/Price.php', - 'src/Exception/AuthenticationException.php', - 'src/Exception/RateLimitException.php', - 'examples/quickstart.php', - 'examples/smoke.php', - ]; + $files = $this->publicSurfaceFiles($root); $forbidden = [ 'fixed catalog total' => '~\b\d+\+\s+(commodit|endpoint|api)~i', 'fixed update cadence' => '~(updated|refresh(ed)?)\s+every\s+\d+|every\s+\d+\s+minutes~i', @@ -49,6 +49,32 @@ public function testPublicSurfacesContainNoHighRiskProductClaims(): void } } + /** + * @return list + */ + private function publicSurfaceFiles(string $root): array + { + $files = ['README.md', 'CHANGELOG.md', 'composer.json']; + foreach (['src', 'examples'] as $directory) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $root . '/' . $directory, + \FilesystemIterator::SKIP_DOTS, + ), + ); + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $relative = substr($file->getPathname(), strlen($root) + 1); + $files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + } + } + sort($files); + + return $files; + } + public function testCanonicalDeveloperContractIsDiscoverable(): void { $root = dirname(__DIR__); From e8e9c261cb35cd006865f333b1cf5ec27461aaf3 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 08:23:59 -0400 Subject: [PATCH 2/2] test: validate exact Composer package claims --- composer.json | 13 +++++ scripts/clean-install-smoke.sh | 2 + scripts/validate-public-claims.php | 86 ++++++++++++++++++++++++++++++ tests/PublicClaimsTest.php | 77 ++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 scripts/validate-public-claims.php diff --git a/composer.json b/composer.json index 03a033d..0ab57e1 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,19 @@ "docs": "https://docs.oilpriceapi.com", "issues": "https://github.com/OilpriceAPI/oilpriceapi-php/issues" }, + "archive": { + "exclude": [ + "/.git", + "/.github", + "/.gitignore", + "/.phpunit.result.cache", + "/composer.lock", + "/phpunit.xml.dist", + "/scripts", + "/tests", + "/vendor" + ] + }, "require": { "php": ">=8.1", "ext-curl": "*", diff --git a/scripts/clean-install-smoke.sh b/scripts/clean-install-smoke.sh index 1304dad..3e1df08 100755 --- a/scripts/clean-install-smoke.sh +++ b/scripts/clean-install-smoke.sh @@ -39,6 +39,8 @@ export COMPOSER_ROOT_VERSION=1.0.0 composer init --name=oilpriceapi/example-smoke --no-interaction --quiet composer config --quiet repositories.oilpriceapi "$package_json" composer require "oilpriceapi/oilpriceapi:$sdk_version" --no-interaction --prefer-dist --no-progress --quiet +php "$root_dir/scripts/validate-public-claims.php" \ + "$tmp_dir/consumer/vendor/oilpriceapi/oilpriceapi" quickstart="$tmp_dir/consumer/vendor/oilpriceapi/oilpriceapi/examples/quickstart.php" [[ -f "$quickstart" ]] || { echo "packaged quickstart is missing" >&2; exit 1; } diff --git a/scripts/validate-public-claims.php b/scripts/validate-public-claims.php new file mode 100644 index 0000000..cdaed4c --- /dev/null +++ b/scripts/validate-public-claims.php @@ -0,0 +1,86 @@ + + */ +function oilpriceapiPublicTextFiles(string $root): array +{ + $root = realpath($root) ?: $root; + if (!is_dir($root)) { + throw new InvalidArgumentException(sprintf('Package root does not exist: %s', $root)); + } + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile() || $file->isLink()) { + continue; + } + $content = file_get_contents($file->getPathname()); + if (!is_string($content) || str_contains($content, "\0") || preg_match('//u', $content) !== 1) { + continue; + } + $relative = substr($file->getPathname(), strlen($root) + 1); + $files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + } + sort($files); + + return $files; +} + +/** + * @param list $files + * @return list + */ +function oilpriceapiClaimFailures(string $root, array $files): array +{ + $patterns = [ + 'fixed catalog total' => '~\b\d+\+\s+(commodit|endpoint|tool|api)~i', + 'fixed update cadence' => '~\b(every|updated|refresh(ed)?)\s+(in\s+)?\d+\s+minutes\b~i', + 'unreviewed plan name' => '~\bprofessional(\+|\s+plan)|\bstarter plan\b|\bscale tier\b~i', + 'unreviewed plan price' => '~\$\d+(\.\d+)?\s*(/|per\s+)(mo(nth)?|year)\b~i', + 'uptime or SLA' => '~\b\d+(\.\d+)?%\s+uptime|\bSLA\b~i', + 'price comparison' => '~\bbloomberg\b|\b\d+(\.\d+)?%\s+less\s+cost\b~i', + 'fixed allowance' => '~\b\d[\d,]*\s+(free\s+)?(api\s+requests?|station\s+queries?)\s*(/|per\s+)month\b~i', + 'quota promise' => '~\bdoes\s+not\s+consume.{0,40}\bquota\b|\bunlimited\s+(history|webhooks?|requests?|commodit)~i', + 'universal catalog' => '~\ball\s+(latest\s+)?prices\b|\ball\s+commodit~i', + 'real-time claim' => '~\breal[- ]time\b~i', + 'free-tier claim' => '~\bfree\s+tier\b|\bfree\s+api\s+key\b~i', + 'fixed demo rate' => '~\b\d+\s+(requests?|reqs?\.?)\s*((per|an?)\s+|/\s*)(minutes?|mins?|hours?|hrs?|days?)\b~i', + ]; + + $failures = []; + foreach ($files as $file) { + $content = file_get_contents($root . '/' . $file); + if (!is_string($content)) { + $failures[] = sprintf('%s: unable to read packaged text', $file); + continue; + } + foreach ($patterns as $label => $pattern) { + preg_match_all($pattern, $content, $matches); + foreach ($matches[0] as $match) { + if ($label === 'fixed demo rate' && strtolower($match) === '50 requests/day') { + continue; + } + $failures[] = sprintf('%s: %s matched %s', $file, $label, $match); + } + } + } + + return $failures; +} + +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + $root = $argv[1] ?? ''; + $files = oilpriceapiPublicTextFiles($root); + $failures = oilpriceapiClaimFailures($root, $files); + if ($failures !== []) { + fwrite(STDERR, implode(PHP_EOL, $failures) . PHP_EOL); + exit(1); + } + fwrite(STDOUT, sprintf("validated %d installed Composer package text surfaces\n", count($files))); +} diff --git a/tests/PublicClaimsTest.php b/tests/PublicClaimsTest.php index c0f15ba..36e95d1 100644 --- a/tests/PublicClaimsTest.php +++ b/tests/PublicClaimsTest.php @@ -7,6 +7,8 @@ use OilPriceAPI\Client; use PHPUnit\Framework\TestCase; +require_once dirname(__DIR__) . '/scripts/validate-public-claims.php'; + final class PublicClaimsTest extends TestCase { public function testPublicSurfaceDiscoveryCoversNestedPackageFiles(): void @@ -19,6 +21,48 @@ public function testPublicSurfaceDiscoveryCoversNestedPackageFiles(): void self::assertContains('src/Exception/TransportException.php', $files); } + public function testFutureComposerTextFilesAndQuotaAliasesCannotBypassDiscovery(): void + { + $root = sys_get_temp_dir() . '/oilpriceapi-claims-' . bin2hex(random_bytes(8)); + mkdir($root . '/docs/nested', recursive: true); + mkdir($root . '/src/data', recursive: true); + file_put_contents($root . '/CUSTOMER_GUIDE', "See current product facts.\n"); + file_put_contents($root . '/docs/nested/guide.md', "Includes 1,000 API requests/month.\n"); + file_put_contents($root . '/src/data/catalog.json', '{"rate": "100 requests per hour"}'); + file_put_contents($root . '/src/data/cache.pyc', "\x00\xff\x00"); + + try { + $files = oilpriceapiPublicTextFiles($root); + self::assertContains('CUSTOMER_GUIDE', $files); + self::assertContains('docs/nested/guide.md', $files); + self::assertContains('src/data/catalog.json', $files); + self::assertNotContains('src/data/cache.pyc', $files); + + $failures = oilpriceapiClaimFailures($root, $files); + self::assertTrue($this->containsFailure($failures, 'docs/nested/guide.md', 'fixed allowance')); + self::assertTrue($this->containsFailure($failures, 'src/data/catalog.json', 'fixed demo rate')); + } finally { + $this->removeDirectory($root); + } + } + + public function testPackagedSmokeScansTheExactInstalledComposerArchive(): void + { + $root = dirname(__DIR__); + $smoke = (string) file_get_contents($root . '/scripts/clean-install-smoke.sh'); + self::assertStringContainsString('validate-public-claims.php', $smoke); + self::assertStringContainsString('vendor/oilpriceapi/oilpriceapi', $smoke); + + $composer = json_decode( + (string) file_get_contents($root . '/composer.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + foreach (['/.github', '/scripts', '/tests', '/vendor'] as $devOnlyPath) { + self::assertContains($devOnlyPath, $composer['archive']['exclude']); + } + } + public function testPublicSurfacesContainNoHighRiskProductClaims(): void { $root = dirname(__DIR__); @@ -75,6 +119,39 @@ private function publicSurfaceFiles(string $root): array return $files; } + /** + * @param list $failures + */ + private function containsFailure(array $failures, string $file, string $label): bool + { + foreach ($failures as $failure) { + if (str_contains($failure, $file) && str_contains($failure, $label)) { + return true; + } + } + + return false; + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $path) { + if ($path->isDir()) { + rmdir($path->getPathname()); + } else { + unlink($path->getPathname()); + } + } + rmdir($directory); + } + public function testCanonicalDeveloperContractIsDiscoverable(): void { $root = dirname(__DIR__);