You've already forked wc-licensed-product-client
Security classes: - ResponseSignature: HMAC-SHA256 signing and verification - StringEncoder: XOR-based string obfuscation for source code - IntegrityChecker: Source file hash verification - SignatureException, IntegrityException for error handling SecureLicenseClient: - Verifies server response signatures - Prevents response tampering and replay attacks - Per-license derived signing keys - Optional code integrity checking Documentation: - docs/server-implementation.md with complete WordPress/WooCommerce integration guide for signing responses Tests: - 34 new security tests (66 total, all passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
219 lines
6.3 KiB
PHP
219 lines
6.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
|
|
|
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
|
|
use Magdev\WcLicensedProductClient\Security\IntegrityException;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
#[CoversClass(IntegrityChecker::class)]
|
|
#[CoversClass(IntegrityException::class)]
|
|
final class IntegrityCheckerTest extends TestCase
|
|
{
|
|
private string $tempDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->tempDir = sys_get_temp_dir() . '/integrity_test_' . uniqid();
|
|
mkdir($this->tempDir, 0755, true);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->removeDirectory($this->tempDir);
|
|
}
|
|
|
|
#[Test]
|
|
public function itPassesForUnmodifiedFiles(): void
|
|
{
|
|
$this->createFile('test.php', '<?php echo "hello";');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['test.php']);
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
$verifier->verify();
|
|
|
|
self::assertTrue($verifier->isValid());
|
|
}
|
|
|
|
#[Test]
|
|
public function itFailsForModifiedFiles(): void
|
|
{
|
|
$this->createFile('test.php', '<?php echo "hello";');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['test.php']);
|
|
|
|
// Modify the file
|
|
$this->createFile('test.php', '<?php echo "world";');
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
|
|
$this->expectException(IntegrityException::class);
|
|
$this->expectExceptionMessage('Modified file: test.php');
|
|
|
|
$verifier->verify();
|
|
}
|
|
|
|
#[Test]
|
|
public function itFailsForMissingFiles(): void
|
|
{
|
|
$this->createFile('test.php', '<?php echo "hello";');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['test.php']);
|
|
|
|
// Delete the file
|
|
unlink($this->tempDir . '/test.php');
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
|
|
$this->expectException(IntegrityException::class);
|
|
$this->expectExceptionMessage('Missing file: test.php');
|
|
|
|
$verifier->verify();
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsFailureDetailsInException(): void
|
|
{
|
|
$this->createFile('file1.php', 'content1');
|
|
$this->createFile('file2.php', 'content2');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['file1.php', 'file2.php']);
|
|
|
|
// Modify both files
|
|
$this->createFile('file1.php', 'modified1');
|
|
$this->createFile('file2.php', 'modified2');
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
|
|
try {
|
|
$verifier->verify();
|
|
self::fail('Expected IntegrityException');
|
|
} catch (IntegrityException $e) {
|
|
self::assertCount(2, $e->failures);
|
|
self::assertContains('Modified file: file1.php', $e->failures);
|
|
self::assertContains('Modified file: file2.php', $e->failures);
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function itIsValidReturnsFalseForModifiedFiles(): void
|
|
{
|
|
$this->createFile('test.php', 'original');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['test.php']);
|
|
|
|
$this->createFile('test.php', 'modified');
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
|
|
self::assertFalse($verifier->isValid());
|
|
}
|
|
|
|
#[Test]
|
|
public function itGeneratesConsistentHashes(): void
|
|
{
|
|
$this->createFile('test.php', '<?php echo "hello";');
|
|
|
|
$checker1 = new IntegrityChecker([], $this->tempDir);
|
|
$checker2 = new IntegrityChecker([], $this->tempDir);
|
|
|
|
$hash1 = $checker1->generateHashes(['test.php']);
|
|
$hash2 = $checker2->generateHashes(['test.php']);
|
|
|
|
self::assertSame($hash1, $hash2);
|
|
}
|
|
|
|
#[Test]
|
|
public function itNormalizesLineEndings(): void
|
|
{
|
|
// Create file with Unix line endings
|
|
$this->createFile('test.php', "line1\nline2\n");
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hash1 = $checker->generateHashes(['test.php']);
|
|
|
|
// Create same file with Windows line endings
|
|
$this->createFile('test.php', "line1\r\nline2\r\n");
|
|
|
|
$hash2 = $checker->generateHashes(['test.php']);
|
|
|
|
self::assertSame($hash1['test.php'], $hash2['test.php']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGeneratesPhpCode(): void
|
|
{
|
|
$this->createFile('test.php', 'content');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$code = $checker->generateHashesAsPhpCode(['test.php']);
|
|
|
|
self::assertStringContainsString('[', $code);
|
|
self::assertStringContainsString(']', $code);
|
|
self::assertStringContainsString("'test.php'", $code);
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsForNonExistentFileInGenerate(): void
|
|
{
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$checker->generateHashes(['nonexistent.php']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itHandlesSubdirectories(): void
|
|
{
|
|
mkdir($this->tempDir . '/subdir', 0755, true);
|
|
$this->createFile('subdir/test.php', 'content');
|
|
|
|
$checker = new IntegrityChecker([], $this->tempDir);
|
|
$hashes = $checker->generateHashes(['subdir/test.php']);
|
|
|
|
self::assertArrayHasKey('subdir/test.php', $hashes);
|
|
|
|
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
|
self::assertTrue($verifier->isValid());
|
|
}
|
|
|
|
private function createFile(string $relativePath, string $content): void
|
|
{
|
|
$fullPath = $this->tempDir . '/' . $relativePath;
|
|
$dir = dirname($fullPath);
|
|
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
file_put_contents($fullPath, $content);
|
|
}
|
|
|
|
private function removeDirectory(string $dir): void
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$files = array_diff(scandir($dir), ['.', '..']);
|
|
|
|
foreach ($files as $file) {
|
|
$path = $dir . '/' . $file;
|
|
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
|
|
}
|
|
|
|
rmdir($dir);
|
|
}
|
|
}
|