fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
153
tests/Unit/Framework/DI/InitializerDependencyGraphTest.php
Normal file
153
tests/Unit/Framework/DI/InitializerDependencyGraphTest.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\DI;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\Exceptions\InitializerCycleException;
|
||||
use App\Framework\DI\InitializerDependencyAnalyzer;
|
||||
use App\Framework\DI\InitializerDependencyGraph;
|
||||
use App\Framework\Reflection\ReflectionService;
|
||||
use App\Framework\Reflection\SimpleReflectionService;
|
||||
|
||||
describe('InitializerDependencyGraph', function () {
|
||||
it('can add initializers and build execution order', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$graph = new InitializerDependencyGraph($reflectionService);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceA',
|
||||
ClassName::create('App\Test\ServiceAInitializer'),
|
||||
MethodName::create('__invoke')
|
||||
);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceB',
|
||||
ClassName::create('App\Test\ServiceBInitializer'),
|
||||
MethodName::create('__invoke')
|
||||
);
|
||||
|
||||
expect($graph->hasNode('ServiceA'))->toBeTrue();
|
||||
expect($graph->hasNode('ServiceB'))->toBeTrue();
|
||||
expect($graph->hasNode('ServiceC'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses explicit dependencies when provided', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$graph = new InitializerDependencyGraph($reflectionService);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceA',
|
||||
ClassName::create('App\Test\ServiceAInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceB', 'ServiceC'], // Explicit dependencies
|
||||
100 // Priority
|
||||
);
|
||||
|
||||
$node = $graph->getNode('ServiceA');
|
||||
expect($node)->not->toBeNull();
|
||||
expect($node->dependencies)->toBe(['ServiceB', 'ServiceC']);
|
||||
});
|
||||
|
||||
it('detects circular dependencies', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$graph = new InitializerDependencyGraph($reflectionService);
|
||||
|
||||
// Create a cycle: A -> B -> A
|
||||
$graph->addInitializer(
|
||||
'ServiceA',
|
||||
ClassName::create('App\Test\ServiceAInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceB'] // A depends on B
|
||||
);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceB',
|
||||
ClassName::create('App\Test\ServiceBInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceA'] // B depends on A (cycle!)
|
||||
);
|
||||
|
||||
expect(fn() => $graph->getExecutionOrder())->toThrow(InitializerCycleException::class);
|
||||
});
|
||||
|
||||
it('provides cycle information with dependency paths', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$graph = new InitializerDependencyGraph($reflectionService);
|
||||
|
||||
// Create a cycle: A -> B -> A
|
||||
$graph->addInitializer(
|
||||
'ServiceA',
|
||||
ClassName::create('App\Test\ServiceAInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceB']
|
||||
);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceB',
|
||||
ClassName::create('App\Test\ServiceBInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceA']
|
||||
);
|
||||
|
||||
try {
|
||||
$graph->getExecutionOrder();
|
||||
expect(false)->toBeTrue(); // Should not reach here
|
||||
} catch (InitializerCycleException $e) {
|
||||
$cycles = $e->getCycles();
|
||||
expect($cycles)->not->toBeEmpty();
|
||||
|
||||
$paths = $e->getDependencyPaths();
|
||||
expect($paths)->not->toBeEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates correct execution order for linear dependencies', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$graph = new InitializerDependencyGraph($reflectionService);
|
||||
|
||||
// C depends on B, B depends on A
|
||||
$graph->addInitializer(
|
||||
'ServiceA',
|
||||
ClassName::create('App\Test\ServiceAInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
[] // No dependencies
|
||||
);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceB',
|
||||
ClassName::create('App\Test\ServiceBInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceA'] // B depends on A
|
||||
);
|
||||
|
||||
$graph->addInitializer(
|
||||
'ServiceC',
|
||||
ClassName::create('App\Test\ServiceCInitializer'),
|
||||
MethodName::create('__invoke'),
|
||||
['ServiceB'] // C depends on B
|
||||
);
|
||||
|
||||
$order = $graph->getExecutionOrder();
|
||||
|
||||
// A should come before B, B should come before C
|
||||
$indexA = array_search('ServiceA', $order, true);
|
||||
$indexB = array_search('ServiceB', $order, true);
|
||||
$indexC = array_search('ServiceC', $order, true);
|
||||
|
||||
expect($indexA)->toBeLessThan($indexB);
|
||||
expect($indexB)->toBeLessThan($indexC);
|
||||
});
|
||||
|
||||
it('integrates with InitializerDependencyAnalyzer when provided', function () {
|
||||
$reflectionService = new SimpleReflectionService();
|
||||
$analyzer = new InitializerDependencyAnalyzer(null);
|
||||
$graph = new InitializerDependencyGraph($reflectionService, $analyzer);
|
||||
|
||||
// Graph should accept analyzer without errors
|
||||
expect($graph)->toBeInstanceOf(InitializerDependencyGraph::class);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\Driver\InMemoryCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Storage\DiscoveryCacheManager;
|
||||
use App\Framework\Discovery\UnifiedDiscoveryService;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryContext;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryOptions;
|
||||
use App\Framework\Discovery\ValueObjects\ScanType;
|
||||
use App\Framework\Filesystem\FileSystemService;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
beforeEach(function () {
|
||||
// Setup cache and services similar to DiscoveryCacheIntegrationTest
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
$this->reflectionProvider = new CachedReflectionProvider();
|
||||
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : dirname(__DIR__, 5);
|
||||
$this->pathProvider = new PathProvider($basePath);
|
||||
|
||||
$this->discoveryContext = new DiscoveryContext(
|
||||
paths: [$this->pathProvider->getSourcePath()->toString()],
|
||||
scanType: ScanType::FULL,
|
||||
options: DiscoveryOptions::default(),
|
||||
startTime: $this->clock->now(),
|
||||
executionContext: null
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24
|
||||
);
|
||||
|
||||
// Perform discovery to create cache
|
||||
$configuration = new DiscoveryConfiguration(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: true,
|
||||
enableMemoryMonitoring: false,
|
||||
memoryLimitMB: 128,
|
||||
maxFilesPerBatch: 100
|
||||
);
|
||||
|
||||
$discoveryService = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $configuration
|
||||
);
|
||||
|
||||
$this->discoveredRegistry = $discoveryService->discover();
|
||||
|
||||
// Store in cache
|
||||
$this->cacheManager->store($this->discoveryContext, $this->discoveredRegistry);
|
||||
});
|
||||
|
||||
test('discovery cache can be loaded', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
expect($cachedRegistry)->not->toBeNull()
|
||||
->and($cachedRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
})->skip('Requires existing cache');
|
||||
|
||||
test('cached registry has content', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
expect($cachedRegistry->hasContent())->toBeTrue()
|
||||
->and($cachedRegistry->isEmpty())->toBeFalse();
|
||||
});
|
||||
|
||||
test('cached registry contains routes', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$routeClass = Route::class;
|
||||
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
|
||||
|
||||
expect($routeAttributes)->toBeArray()
|
||||
->and(count($routeAttributes))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('route attributes are correctly deserialized', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$routeClass = Route::class;
|
||||
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
|
||||
|
||||
if (empty($routeAttributes)) {
|
||||
$this->markTestSkipped('No routes found in cache');
|
||||
}
|
||||
|
||||
$firstRoute = $routeAttributes[0];
|
||||
|
||||
expect($firstRoute)->toBeInstanceOf(\App\Framework\Discovery\ValueObjects\DiscoveredAttribute::class)
|
||||
->and($firstRoute->className)->toBeInstanceOf(\App\Framework\Core\ValueObjects\ClassName::class)
|
||||
->and($firstRoute->attributeClass)->toBe($routeClass);
|
||||
});
|
||||
|
||||
test('route attributes can create route instances', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$routeClass = Route::class;
|
||||
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
|
||||
|
||||
if (empty($routeAttributes)) {
|
||||
$this->markTestSkipped('No routes found in cache');
|
||||
}
|
||||
|
||||
$firstRoute = $routeAttributes[0];
|
||||
|
||||
expect($firstRoute)->toBeInstanceOf(\App\Framework\Discovery\ValueObjects\DiscoveredAttribute::class);
|
||||
|
||||
// Try to create Route instance from arguments
|
||||
try {
|
||||
$routeInstance = new Route(...$firstRoute->arguments);
|
||||
expect($routeInstance)->toBeInstanceOf(Route::class);
|
||||
} catch (\Throwable $e) {
|
||||
$this->fail("Failed to create Route instance: {$e->getMessage()}");
|
||||
}
|
||||
});
|
||||
|
||||
test('registry can be serialized and deserialized', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$routeClass = Route::class;
|
||||
$originalRouteCount = count($cachedRegistry->attributes->get($routeClass));
|
||||
|
||||
// Serialize and deserialize
|
||||
$serialized = serialize($cachedRegistry);
|
||||
$deserialized = unserialize($serialized);
|
||||
|
||||
expect($deserialized)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
$deserializedRouteCount = count($deserialized->attributes->get($routeClass));
|
||||
|
||||
expect($deserializedRouteCount)->toBe($originalRouteCount);
|
||||
});
|
||||
|
||||
test('cached registry content summary is accurate', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$summary = $cachedRegistry->getContentSummary();
|
||||
|
||||
expect($summary)->toBeArray()
|
||||
->and($summary)->toHaveKey('routes')
|
||||
->and($summary)->toHaveKey('commands')
|
||||
->and($summary)->toHaveKey('initializers');
|
||||
|
||||
// Verify route count matches actual count
|
||||
$routeClass = Route::class;
|
||||
$actualRouteCount = count($cachedRegistry->attributes->get($routeClass));
|
||||
|
||||
expect($summary['routes'])->toBe($actualRouteCount);
|
||||
});
|
||||
|
||||
test('all attribute types are accessible', function () {
|
||||
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
|
||||
|
||||
if ($cachedRegistry === null) {
|
||||
$this->markTestSkipped('No cached registry found - run discovery first');
|
||||
}
|
||||
|
||||
$allTypes = $cachedRegistry->attributes->getAllTypes();
|
||||
|
||||
expect($allTypes)->toBeArray();
|
||||
|
||||
// Verify we can access each type
|
||||
foreach ($allTypes as $type) {
|
||||
$attributes = $cachedRegistry->attributes->get($type);
|
||||
$count = $cachedRegistry->attributes->getCount($type);
|
||||
|
||||
expect($attributes)->toBeArray()
|
||||
->and($count)->toBe(count($attributes));
|
||||
}
|
||||
});
|
||||
|
||||
test('cache entry can be retrieved directly', function () {
|
||||
$cacheKey = $this->discoveryContext->getCacheKey();
|
||||
$cacheResult = $this->cache->get($cacheKey);
|
||||
|
||||
if (!$cacheResult->isHit) {
|
||||
$this->markTestSkipped('No cache entry found - run discovery first');
|
||||
}
|
||||
|
||||
$cachedData = $cacheResult->value;
|
||||
|
||||
expect($cachedData)->not->toBeNull();
|
||||
|
||||
if (is_array($cachedData)) {
|
||||
expect($cachedData)->toHaveKey('registry');
|
||||
}
|
||||
});
|
||||
|
||||
136
tests/Unit/Framework/Http/Session/CsrfProtectionTest.php
Normal file
136
tests/Unit/Framework/Http/Session/CsrfProtectionTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Http\Session\CsrfProtection;
|
||||
use App\Framework\Http\Session\Session;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
use App\Framework\Http\Session\ValueObjects\CsrfDataCollection;
|
||||
use App\Framework\Http\Session\ValueObjects\FormDataCollection;
|
||||
use App\Framework\Http\Session\ValueObjects\FlashMessageCollection;
|
||||
use App\Framework\Http\Session\ValueObjects\SecurityDataCollection;
|
||||
use App\Framework\Http\Session\ValueObjects\SessionData;
|
||||
use App\Framework\Http\Session\ValueObjects\ValidationErrorCollection;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
use App\Framework\Security\CsrfTokenGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->clock = new SystemClock();
|
||||
$this->tokenGenerator = new CsrfTokenGenerator(new SecureRandomGenerator());
|
||||
$this->sessionId = SessionId::fromString('test-session-' . uniqid());
|
||||
|
||||
$this->session = Session::fromArray(
|
||||
$this->sessionId,
|
||||
$this->clock,
|
||||
$this->tokenGenerator,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->csrfProtection = $this->session->csrf;
|
||||
});
|
||||
|
||||
it('generates a new token every time', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
$token1 = $this->csrfProtection->generateToken($formId);
|
||||
$token2 = $this->csrfProtection->generateToken($formId);
|
||||
|
||||
// Tokens should be different (no reuse)
|
||||
expect($token1->toString())->not->toBe($token2->toString());
|
||||
});
|
||||
|
||||
it('generates valid 64-character hex tokens', function () {
|
||||
$token = $this->csrfProtection->generateToken('test-form');
|
||||
|
||||
expect($token->toString())->toHaveLength(64);
|
||||
expect(ctype_xdigit($token->toString()))->toBeTrue();
|
||||
});
|
||||
|
||||
it('stores multiple tokens per form', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
$token1 = $this->csrfProtection->generateToken($formId);
|
||||
$token2 = $this->csrfProtection->generateToken($formId);
|
||||
$token3 = $this->csrfProtection->generateToken($formId);
|
||||
|
||||
$count = $this->csrfProtection->getActiveTokenCount($formId);
|
||||
|
||||
// Should have 3 tokens
|
||||
expect($count)->toBe(3);
|
||||
});
|
||||
|
||||
it('validates correct token', function () {
|
||||
$formId = 'test-form';
|
||||
$token = $this->csrfProtection->generateToken($formId);
|
||||
|
||||
$result = $this->csrfProtection->validateTokenWithDebug($formId, $token);
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects invalid token', function () {
|
||||
$formId = 'test-form';
|
||||
$this->csrfProtection->generateToken($formId);
|
||||
|
||||
$invalidToken = CsrfToken::fromString(str_repeat('0', 64));
|
||||
|
||||
$result = $this->csrfProtection->validateTokenWithDebug($formId, $invalidToken);
|
||||
|
||||
expect($result['valid'])->toBeFalse();
|
||||
expect($result['debug']['reason'])->toBe('No matching token found in session');
|
||||
});
|
||||
|
||||
it('allows token reuse within resubmit window', function () {
|
||||
$formId = 'test-form';
|
||||
$token = $this->csrfProtection->generateToken($formId);
|
||||
|
||||
// First validation
|
||||
$result1 = $this->csrfProtection->validateTokenWithDebug($formId, $token);
|
||||
expect($result1['valid'])->toBeTrue();
|
||||
|
||||
// Second validation within resubmit window (should still work)
|
||||
$result2 = $this->csrfProtection->validateTokenWithDebug($formId, $token);
|
||||
expect($result2['valid'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('limits tokens per form to maximum', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
// Generate more than MAX_TOKENS_PER_FORM (3)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->csrfProtection->generateToken($formId);
|
||||
}
|
||||
|
||||
$count = $this->csrfProtection->getActiveTokenCount($formId);
|
||||
|
||||
// Should be limited to 3
|
||||
expect($count)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('handles multiple forms independently', function () {
|
||||
$formId1 = 'form-1';
|
||||
$formId2 = 'form-2';
|
||||
|
||||
$token1 = $this->csrfProtection->generateToken($formId1);
|
||||
$token2 = $this->csrfProtection->generateToken($formId2);
|
||||
|
||||
// Tokens should be different
|
||||
expect($token1->toString())->not->toBe($token2->toString());
|
||||
|
||||
// Each form should have its own token
|
||||
expect($this->csrfProtection->getActiveTokenCount($formId1))->toBe(1);
|
||||
expect($this->csrfProtection->getActiveTokenCount($formId2))->toBe(1);
|
||||
|
||||
// Validation should work independently
|
||||
expect($this->csrfProtection->validateToken($formId1, $token1))->toBeTrue();
|
||||
expect($this->csrfProtection->validateToken($formId2, $token2))->toBeTrue();
|
||||
|
||||
// Cross-validation should fail
|
||||
expect($this->csrfProtection->validateToken($formId1, $token2))->toBeFalse();
|
||||
expect($this->csrfProtection->validateToken($formId2, $token1))->toBeFalse();
|
||||
});
|
||||
|
||||
|
||||
103
tests/Unit/Framework/Http/Session/SessionStorageLockingTest.php
Normal file
103
tests/Unit/Framework/Http/Session/SessionStorageLockingTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Http\Session\FileSessionStorage;
|
||||
use App\Framework\Http\Session\InMemorySessionStorage;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tempDir = sys_get_temp_dir() . '/php_sessions_test_' . uniqid();
|
||||
mkdir($this->tempDir, 0700, true);
|
||||
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileStorage = new FileSessionStorage($this->tempDir, $this->clock);
|
||||
$this->memoryStorage = new InMemorySessionStorage();
|
||||
|
||||
$this->sessionId = SessionId::fromString('test-session-' . uniqid());
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Cleanup
|
||||
if (isset($this->tempDir) && is_dir($this->tempDir)) {
|
||||
array_map('unlink', glob($this->tempDir . '/*'));
|
||||
rmdir($this->tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('acquires and releases lock for file storage', function () {
|
||||
$acquired = $this->fileStorage->acquireLock($this->sessionId, 1);
|
||||
|
||||
expect($acquired)->toBeTrue();
|
||||
|
||||
// Should be able to release
|
||||
$this->fileStorage->releaseLock($this->sessionId);
|
||||
|
||||
// Should be able to acquire again
|
||||
$acquired2 = $this->fileStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired2)->toBeTrue();
|
||||
|
||||
$this->fileStorage->releaseLock($this->sessionId);
|
||||
});
|
||||
|
||||
it('prevents concurrent locks for file storage', function () {
|
||||
// Acquire lock in first "process"
|
||||
$acquired1 = $this->fileStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired1)->toBeTrue();
|
||||
|
||||
// Try to acquire same lock in second "process" (should fail or timeout)
|
||||
$acquired2 = $this->fileStorage->acquireLock($this->sessionId, 1);
|
||||
|
||||
// In single-threaded test, second acquisition might succeed
|
||||
// But in real scenario with concurrent processes, it would fail
|
||||
// This test verifies the locking mechanism exists
|
||||
|
||||
$this->fileStorage->releaseLock($this->sessionId);
|
||||
});
|
||||
|
||||
it('acquires and releases lock for memory storage', function () {
|
||||
$acquired = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
|
||||
expect($acquired)->toBeTrue();
|
||||
|
||||
// Should be able to release
|
||||
$this->memoryStorage->releaseLock($this->sessionId);
|
||||
|
||||
// Should be able to acquire again
|
||||
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired2)->toBeTrue();
|
||||
|
||||
$this->memoryStorage->releaseLock($this->sessionId);
|
||||
});
|
||||
|
||||
it('prevents concurrent locks for memory storage', function () {
|
||||
// Acquire lock
|
||||
$acquired1 = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired1)->toBeTrue();
|
||||
|
||||
// Try to acquire same lock (should fail)
|
||||
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired2)->toBeFalse();
|
||||
|
||||
// Release and try again
|
||||
$this->memoryStorage->releaseLock($this->sessionId);
|
||||
$acquired3 = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired3)->toBeTrue();
|
||||
|
||||
$this->memoryStorage->releaseLock($this->sessionId);
|
||||
});
|
||||
|
||||
it('handles lock timeout correctly', function () {
|
||||
// Acquire lock
|
||||
$acquired1 = $this->memoryStorage->acquireLock($this->sessionId, 1);
|
||||
expect($acquired1)->toBeTrue();
|
||||
|
||||
// Try to acquire with very short timeout (should fail)
|
||||
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 0);
|
||||
expect($acquired2)->toBeFalse();
|
||||
|
||||
$this->memoryStorage->releaseLock($this->sessionId);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Attributes;
|
||||
|
||||
use App\Framework\LiveComponents\Attributes\Island;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class IslandTest extends TestCase
|
||||
{
|
||||
public function test_can_create_island_attribute_with_defaults(): void
|
||||
{
|
||||
$island = new Island();
|
||||
|
||||
$this->assertTrue($island->isolated);
|
||||
$this->assertFalse($island->lazy);
|
||||
$this->assertNull($island->placeholder);
|
||||
}
|
||||
|
||||
public function test_can_create_island_attribute_with_custom_values(): void
|
||||
{
|
||||
$island = new Island(
|
||||
isolated: true,
|
||||
lazy: true,
|
||||
placeholder: 'Loading widget...'
|
||||
);
|
||||
|
||||
$this->assertTrue($island->isolated);
|
||||
$this->assertTrue($island->lazy);
|
||||
$this->assertSame('Loading widget...', $island->placeholder);
|
||||
}
|
||||
|
||||
public function test_can_create_non_isolated_island(): void
|
||||
{
|
||||
$island = new Island(isolated: false);
|
||||
|
||||
$this->assertFalse($island->isolated);
|
||||
$this->assertFalse($island->lazy);
|
||||
$this->assertNull($island->placeholder);
|
||||
}
|
||||
|
||||
public function test_can_create_lazy_island_without_placeholder(): void
|
||||
{
|
||||
$island = new Island(lazy: true);
|
||||
|
||||
$this->assertTrue($island->isolated);
|
||||
$this->assertTrue($island->lazy);
|
||||
$this->assertNull($island->placeholder);
|
||||
}
|
||||
|
||||
public function test_can_create_lazy_island_with_placeholder(): void
|
||||
{
|
||||
$island = new Island(
|
||||
lazy: true,
|
||||
placeholder: 'Please wait...'
|
||||
);
|
||||
|
||||
$this->assertTrue($island->isolated);
|
||||
$this->assertTrue($island->lazy);
|
||||
$this->assertSame('Please wait...', $island->placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Middleware;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Middleware\ComponentMiddlewarePipeline;
|
||||
use App\Framework\LiveComponents\Middleware\MiddlewareRegistration;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentState;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
||||
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
|
||||
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
|
||||
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware2;
|
||||
use Tests\Unit\Framework\LiveComponents\Middleware\TestPassThroughMiddleware;
|
||||
use Tests\Unit\Framework\LiveComponents\Middleware\TestCaptureMiddleware;
|
||||
|
||||
// Ensure test middleware classes are loaded
|
||||
require_once __DIR__ . '/TestMiddleware.php';
|
||||
|
||||
describe('ComponentMiddlewarePipeline', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
});
|
||||
|
||||
it('executes middleware in priority order', function () {
|
||||
$executionOrder = [];
|
||||
|
||||
// Register middleware in container
|
||||
$this->container->instance(TestMiddleware1::class, new TestMiddleware1($executionOrder));
|
||||
$this->container->instance(TestMiddleware2::class, new TestMiddleware2($executionOrder));
|
||||
|
||||
// Create pipeline with middleware (higher priority first)
|
||||
$middlewares = [
|
||||
new MiddlewareRegistration(TestMiddleware1::class, priority: 200),
|
||||
new MiddlewareRegistration(TestMiddleware2::class, priority: 100),
|
||||
];
|
||||
|
||||
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
|
||||
|
||||
// Create test component
|
||||
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
|
||||
public function __construct(public ComponentId $id, public ComponentState $state) {}
|
||||
public function getRenderData() {
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute pipeline
|
||||
$result = $pipeline->process(
|
||||
$component,
|
||||
'testAction',
|
||||
ActionParameters::fromArray([]),
|
||||
fn($c, $a, $p) => new ComponentUpdate(
|
||||
component: $c,
|
||||
state: LiveComponentState::fromArray([]),
|
||||
events: []
|
||||
)
|
||||
);
|
||||
|
||||
// Middleware should execute in priority order (higher first)
|
||||
expect($executionOrder)->toBe(['middleware1', 'middleware2']);
|
||||
});
|
||||
|
||||
it('passes component, action, and params through middleware chain', function () {
|
||||
$receivedComponent = null;
|
||||
$receivedAction = null;
|
||||
$receivedParams = null;
|
||||
|
||||
$middleware = new TestCaptureMiddleware($receivedComponent, $receivedAction, $receivedParams);
|
||||
$this->container->instance(TestCaptureMiddleware::class, $middleware);
|
||||
|
||||
$middlewares = [
|
||||
new MiddlewareRegistration(TestCaptureMiddleware::class, priority: 100),
|
||||
];
|
||||
|
||||
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
|
||||
|
||||
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
|
||||
public function __construct(public ComponentId $id, public ComponentState $state) {}
|
||||
public function getRenderData() {
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
|
||||
}
|
||||
};
|
||||
|
||||
$params = ActionParameters::fromArray(['test' => 'value']);
|
||||
|
||||
$pipeline->process(
|
||||
$component,
|
||||
'testAction',
|
||||
$params,
|
||||
fn($c, $a, $p) => new ComponentUpdate(
|
||||
component: $c,
|
||||
state: LiveComponentState::fromArray([]),
|
||||
events: []
|
||||
)
|
||||
);
|
||||
|
||||
expect($receivedComponent)->toBe($component);
|
||||
expect($receivedAction)->toBe('testAction');
|
||||
expect($receivedParams)->toBe($params);
|
||||
});
|
||||
|
||||
it('returns action handler result', function () {
|
||||
$expectedResult = new ComponentUpdate(
|
||||
component: new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
|
||||
public function __construct(public ComponentId $id, public ComponentState $state) {}
|
||||
public function getRenderData() {
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
|
||||
}
|
||||
},
|
||||
state: LiveComponentState::fromArray(['result' => 'success']),
|
||||
events: []
|
||||
);
|
||||
|
||||
$middleware = new TestPassThroughMiddleware();
|
||||
$this->container->instance(TestPassThroughMiddleware::class, $middleware);
|
||||
|
||||
$middlewares = [
|
||||
new MiddlewareRegistration(TestPassThroughMiddleware::class, priority: 100),
|
||||
];
|
||||
|
||||
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
|
||||
|
||||
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
|
||||
public function __construct(public ComponentId $id, public ComponentState $state) {}
|
||||
public function getRenderData() {
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $pipeline->process(
|
||||
$component,
|
||||
'testAction',
|
||||
ActionParameters::fromArray([]),
|
||||
fn($c, $a, $p) => $expectedResult
|
||||
);
|
||||
|
||||
expect($result)->toBe($expectedResult);
|
||||
});
|
||||
|
||||
it('handles empty middleware array', function () {
|
||||
$pipeline = new ComponentMiddlewarePipeline([], $this->container);
|
||||
|
||||
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
|
||||
public function __construct(public ComponentId $id, public ComponentState $state) {}
|
||||
public function getRenderData() {
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
|
||||
}
|
||||
};
|
||||
|
||||
$expectedResult = new ComponentUpdate(
|
||||
component: $component,
|
||||
state: LiveComponentState::fromArray([]),
|
||||
events: []
|
||||
);
|
||||
|
||||
$result = $pipeline->process(
|
||||
$component,
|
||||
'testAction',
|
||||
ActionParameters::fromArray([]),
|
||||
fn($c, $a, $p) => $expectedResult
|
||||
);
|
||||
|
||||
expect($result)->toBe($expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Middleware;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\InterfaceRegistry;
|
||||
use App\Framework\Discovery\Results\TemplateRegistry;
|
||||
use App\Framework\Discovery\ValueObjects\AttributeTarget;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
use App\Framework\LiveComponents\Attributes\Middleware as MiddlewareAttribute;
|
||||
use App\Framework\LiveComponents\Middleware\MiddlewareCollector;
|
||||
use App\Framework\LiveComponents\Middleware\LoggingMiddleware;
|
||||
|
||||
describe('MiddlewareCollector', function () {
|
||||
beforeEach(function () {
|
||||
$this->attributeRegistry = new AttributeRegistry();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: $this->attributeRegistry,
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->collector = new MiddlewareCollector($this->discoveryRegistry);
|
||||
});
|
||||
|
||||
it('collects component-level middleware', function () {
|
||||
$componentClass = ClassName::create('Test\\Component');
|
||||
|
||||
// Add component-level middleware attribute
|
||||
$discovered = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_CLASS,
|
||||
arguments: [LoggingMiddleware::class, 100]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
|
||||
|
||||
$middlewares = $this->collector->collectForAction(
|
||||
$componentClass,
|
||||
MethodName::create('testAction')
|
||||
);
|
||||
|
||||
expect($middlewares)->toHaveCount(1);
|
||||
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
|
||||
expect($middlewares[0]->priority)->toBe(100);
|
||||
});
|
||||
|
||||
it('collects action-level middleware', function () {
|
||||
$componentClass = ClassName::create('Test\\Component');
|
||||
$actionMethod = MethodName::create('testAction');
|
||||
|
||||
// Add action-level middleware attribute
|
||||
$discovered = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_METHOD,
|
||||
methodName: $actionMethod,
|
||||
arguments: [LoggingMiddleware::class, 200]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
|
||||
|
||||
$middlewares = $this->collector->collectForAction(
|
||||
$componentClass,
|
||||
$actionMethod
|
||||
);
|
||||
|
||||
expect($middlewares)->toHaveCount(1);
|
||||
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
|
||||
expect($middlewares[0]->priority)->toBe(200);
|
||||
});
|
||||
|
||||
it('combines component and action-level middleware', function () {
|
||||
$componentClass = ClassName::create('Test\\Component');
|
||||
$actionMethod = MethodName::create('testAction');
|
||||
|
||||
// Add component-level middleware
|
||||
$componentMiddleware = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_CLASS,
|
||||
arguments: [LoggingMiddleware::class, 100]
|
||||
);
|
||||
|
||||
// Add action-level middleware
|
||||
$actionMiddleware = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_METHOD,
|
||||
methodName: $actionMethod,
|
||||
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 200]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $componentMiddleware);
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $actionMiddleware);
|
||||
|
||||
$middlewares = $this->collector->collectForAction(
|
||||
$componentClass,
|
||||
$actionMethod
|
||||
);
|
||||
|
||||
expect($middlewares)->toHaveCount(2);
|
||||
// Should be sorted by priority (higher first)
|
||||
expect($middlewares[0]->priority)->toBe(200);
|
||||
expect($middlewares[1]->priority)->toBe(100);
|
||||
});
|
||||
|
||||
it('sorts middleware by priority descending', function () {
|
||||
$componentClass = ClassName::create('Test\\Component');
|
||||
|
||||
// Add multiple middleware with different priorities
|
||||
$middleware1 = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_CLASS,
|
||||
arguments: [LoggingMiddleware::class, 50]
|
||||
);
|
||||
|
||||
$middleware2 = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_CLASS,
|
||||
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 150]
|
||||
);
|
||||
|
||||
$middleware3 = new DiscoveredAttribute(
|
||||
className: $componentClass,
|
||||
attributeClass: MiddlewareAttribute::class,
|
||||
target: AttributeTarget::TARGET_CLASS,
|
||||
arguments: [\App\Framework\LiveComponents\Middleware\RateLimitMiddleware::class, 100]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware1);
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware2);
|
||||
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware3);
|
||||
|
||||
$middlewares = $this->collector->collectForAction(
|
||||
$componentClass,
|
||||
MethodName::create('testAction')
|
||||
);
|
||||
|
||||
expect($middlewares)->toHaveCount(3);
|
||||
// Should be sorted by priority descending
|
||||
expect($middlewares[0]->priority)->toBe(150);
|
||||
expect($middlewares[1]->priority)->toBe(100);
|
||||
expect($middlewares[2]->priority)->toBe(50);
|
||||
});
|
||||
|
||||
it('returns empty array when no middleware found', function () {
|
||||
$componentClass = ClassName::create('Test\\Component');
|
||||
|
||||
$middlewares = $this->collector->collectForAction(
|
||||
$componentClass,
|
||||
MethodName::create('testAction')
|
||||
);
|
||||
|
||||
expect($middlewares)->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Middleware;
|
||||
|
||||
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
||||
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
|
||||
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
|
||||
|
||||
it('can create middleware', function () {
|
||||
$order = [];
|
||||
$middleware = new TestMiddleware1($order);
|
||||
|
||||
expect($middleware)->toBeInstanceOf(ComponentMiddlewareInterface::class);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Middleware;
|
||||
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
||||
|
||||
/**
|
||||
* Test Middleware for unit tests
|
||||
*/
|
||||
final class TestMiddleware1 implements ComponentMiddlewareInterface
|
||||
{
|
||||
private array $executionOrder;
|
||||
|
||||
public function __construct(
|
||||
array &$executionOrder
|
||||
) {
|
||||
$this->executionOrder = &$executionOrder;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
LiveComponentContract $component,
|
||||
string $action,
|
||||
ActionParameters $params,
|
||||
callable $next
|
||||
): ComponentUpdate {
|
||||
$this->executionOrder[] = 'middleware1';
|
||||
return $next($component, $action, $params);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestMiddleware2 implements ComponentMiddlewareInterface
|
||||
{
|
||||
private array $executionOrder;
|
||||
|
||||
public function __construct(
|
||||
array &$executionOrder
|
||||
) {
|
||||
$this->executionOrder = &$executionOrder;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
LiveComponentContract $component,
|
||||
string $action,
|
||||
ActionParameters $params,
|
||||
callable $next
|
||||
): ComponentUpdate {
|
||||
$this->executionOrder[] = 'middleware2';
|
||||
return $next($component, $action, $params);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestPassThroughMiddleware implements ComponentMiddlewareInterface
|
||||
{
|
||||
public function handle(
|
||||
LiveComponentContract $component,
|
||||
string $action,
|
||||
ActionParameters $params,
|
||||
callable $next
|
||||
): ComponentUpdate {
|
||||
return $next($component, $action, $params);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestCaptureMiddleware implements ComponentMiddlewareInterface
|
||||
{
|
||||
private ?LiveComponentContract $capturedComponent;
|
||||
private ?string $capturedAction;
|
||||
private ?ActionParameters $capturedParams;
|
||||
|
||||
public function __construct(
|
||||
?LiveComponentContract &$capturedComponent,
|
||||
?string &$capturedAction,
|
||||
?ActionParameters &$capturedParams
|
||||
) {
|
||||
$this->capturedComponent = &$capturedComponent;
|
||||
$this->capturedAction = &$capturedAction;
|
||||
$this->capturedParams = &$capturedParams;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
LiveComponentContract $component,
|
||||
string $action,
|
||||
ActionParameters $params,
|
||||
callable $next
|
||||
): ComponentUpdate {
|
||||
$this->capturedComponent = $component;
|
||||
$this->capturedAction = $action;
|
||||
$this->capturedParams = $params;
|
||||
return $next($component, $action, $params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Performance;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterComponent;
|
||||
use App\Framework\LiveComponents\Attributes\Island;
|
||||
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Performance\ComponentMetadataCompiler;
|
||||
use App\Framework\LiveComponents\Performance\CompiledComponentMetadata;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentState;
|
||||
|
||||
describe('Island Metadata Detection', function () {
|
||||
it('detects Island attribute in component', function () {
|
||||
$compiler = new ComponentMetadataCompiler();
|
||||
|
||||
// Create a test component with Island attribute
|
||||
$componentClass = IslandTestComponent::class;
|
||||
$metadata = $compiler->compile($componentClass);
|
||||
|
||||
expect($metadata->isIsland())->toBeTrue();
|
||||
expect($metadata->getIsland())->not->toBeNull();
|
||||
|
||||
$island = $metadata->getIsland();
|
||||
expect($island['isolated'])->toBeTrue();
|
||||
expect($island['lazy'])->toBeFalse();
|
||||
expect($island['placeholder'])->toBeNull();
|
||||
});
|
||||
|
||||
it('detects Island attribute with lazy loading', function () {
|
||||
$compiler = new ComponentMetadataCompiler();
|
||||
|
||||
$componentClass = LazyIslandTestComponent::class;
|
||||
$metadata = $compiler->compile($componentClass);
|
||||
|
||||
expect($metadata->isIsland())->toBeTrue();
|
||||
|
||||
$island = $metadata->getIsland();
|
||||
expect($island['isolated'])->toBeTrue();
|
||||
expect($island['lazy'])->toBeTrue();
|
||||
expect($island['placeholder'])->toBe('Loading component...');
|
||||
});
|
||||
|
||||
it('returns null for non-island components', function () {
|
||||
$compiler = new ComponentMetadataCompiler();
|
||||
|
||||
$metadata = $compiler->compile(CounterComponent::class);
|
||||
|
||||
expect($metadata->isIsland())->toBeFalse();
|
||||
expect($metadata->getIsland())->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes Island metadata in toArray', function () {
|
||||
$compiler = new ComponentMetadataCompiler();
|
||||
|
||||
$metadata = $compiler->compile(IslandTestComponent::class);
|
||||
$array = $metadata->toArray();
|
||||
|
||||
expect($array)->toHaveKey('island');
|
||||
expect($array['island'])->not->toBeNull();
|
||||
expect($array['island']['isolated'])->toBeTrue();
|
||||
expect($array['island']['lazy'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('deserializes Island metadata from array', function () {
|
||||
$compiler = new ComponentMetadataCompiler();
|
||||
|
||||
$metadata = $compiler->compile(IslandTestComponent::class);
|
||||
$array = $metadata->toArray();
|
||||
|
||||
$restored = CompiledComponentMetadata::fromArray($array);
|
||||
|
||||
expect($restored->isIsland())->toBeTrue();
|
||||
expect($restored->getIsland())->not->toBeNull();
|
||||
|
||||
$island = $restored->getIsland();
|
||||
expect($island['isolated'])->toBeTrue();
|
||||
expect($island['lazy'])->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// Test component classes
|
||||
#[LiveComponent('island-test')]
|
||||
#[Island]
|
||||
final readonly class IslandTestComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public ComponentState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
|
||||
{
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
|
||||
templatePath: 'test',
|
||||
data: []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[LiveComponent('lazy-island-test')]
|
||||
#[Island(isolated: true, lazy: true, placeholder: 'Loading component...')]
|
||||
final readonly class LazyIslandTestComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public ComponentState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
|
||||
{
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
|
||||
templatePath: 'test',
|
||||
data: []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
require_once __DIR__ . '/TestComponents.php';
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\Attributes\Execution\AttributeExecutionContext;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\LiveComponents\Attributes\Action;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Security\ActionValidator;
|
||||
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\ArrayReturnTypeComponent;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\PrimitiveReturnTypeComponent;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\PrivateActionComponent;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\StaticActionComponent;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\ValidActionComponent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ActionValidatorTest extends TestCase
|
||||
{
|
||||
private ActionValidator $validator;
|
||||
private DefaultContainer $container;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->container = new DefaultContainer();
|
||||
$this->validator = new ActionValidator();
|
||||
}
|
||||
|
||||
public function test_validates_valid_action(): void
|
||||
{
|
||||
$component = $this->createValidComponent();
|
||||
$context = $this->createContext($component, 'validAction');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
// Should not throw
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_rejects_reserved_method(): void
|
||||
{
|
||||
$component = $this->createValidComponent();
|
||||
$context = $this->createContext($component, 'onMount');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('reserved method');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_non_existent_method(): void
|
||||
{
|
||||
$component = $this->createValidComponent();
|
||||
$context = $this->createContext($component, 'nonExistentMethod');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('not found');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_private_method(): void
|
||||
{
|
||||
$component = new PrivateActionComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
|
||||
$context = $this->createContext($component, 'privateAction');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('must be public');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_static_method(): void
|
||||
{
|
||||
$component = new StaticActionComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
|
||||
$context = $this->createContext($component, 'staticAction');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('cannot be static');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_primitive_return_type(): void
|
||||
{
|
||||
$component = new PrimitiveReturnTypeComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
|
||||
$context = $this->createContext($component, 'intAction');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('must return a State object');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_array_return_type(): void
|
||||
{
|
||||
$component = new ArrayReturnTypeComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
|
||||
$context = $this->createContext($component, 'arrayAction');
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('must return a State object');
|
||||
|
||||
$this->validator->validate($context, $actionAttribute);
|
||||
}
|
||||
|
||||
private function createValidComponent(): LiveComponentContract
|
||||
{
|
||||
return new ValidActionComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
}
|
||||
|
||||
private function createContext(LiveComponentContract $component, string $methodName): AttributeExecutionContext
|
||||
{
|
||||
$componentClass = ClassName::create($component::class);
|
||||
$method = MethodName::create($methodName);
|
||||
$componentId = $component->id;
|
||||
$actionParameters = ActionParameters::fromArray([]);
|
||||
|
||||
return LiveComponentContextHelper::createForAction(
|
||||
container: $this->container,
|
||||
componentClass: $componentClass,
|
||||
actionMethod: $method,
|
||||
componentId: $componentId,
|
||||
actionParameters: $actionParameters,
|
||||
component: $component
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\Attributes\Execution\AttributeExecutionContext;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Security\Guards\LiveComponentCsrfGuard;
|
||||
use App\Framework\LiveComponents\Security\LiveComponentContextData;
|
||||
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
use App\Framework\Http\Session\CsrfProtection;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
use App\Framework\Security\Guards\CsrfGuard;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LiveComponentCsrfGuardTest extends TestCase
|
||||
{
|
||||
private LiveComponentCsrfGuard $guard;
|
||||
private CsrfGuard $csrfGuard;
|
||||
private SessionInterface&MockObject $session;
|
||||
private CsrfProtection&MockObject $csrfProtection;
|
||||
private DefaultContainer $container;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Since CsrfProtection is final, we skip mocking it
|
||||
// These tests focus on the LiveComponent-specific logic (context extraction, form ID generation)
|
||||
// The actual CSRF validation is tested in integration tests
|
||||
|
||||
$this->session = $this->createMock(SessionInterface::class);
|
||||
$this->csrfGuard = new CsrfGuard($this->session);
|
||||
$this->container = new DefaultContainer();
|
||||
$this->container->instance(CsrfGuard::class, $this->csrfGuard);
|
||||
$this->container->instance(SessionInterface::class, $this->session);
|
||||
|
||||
$this->guard = new LiveComponentCsrfGuard($this->csrfGuard);
|
||||
}
|
||||
|
||||
public function test_validates_valid_csrf_token(): void
|
||||
{
|
||||
// This test is skipped because CsrfProtection is final and cannot be mocked
|
||||
// The CSRF validation logic is tested in integration tests
|
||||
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
|
||||
}
|
||||
|
||||
public function test_rejects_missing_csrf_token(): void
|
||||
{
|
||||
$component = $this->createComponent();
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$context = $this->createContext($component, $params);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('CSRF token is required');
|
||||
|
||||
$this->guard->validate($context);
|
||||
}
|
||||
|
||||
public function test_rejects_context_without_live_component_data(): void
|
||||
{
|
||||
$context = AttributeExecutionContext::forMethod(
|
||||
container: $this->container,
|
||||
className: ClassName::create('TestComponent'),
|
||||
methodName: MethodName::create('testMethod')
|
||||
);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('LiveComponentContextData');
|
||||
|
||||
$this->guard->validate($context);
|
||||
}
|
||||
|
||||
public function test_uses_component_id_as_form_id(): void
|
||||
{
|
||||
// This test is skipped because CsrfProtection is final and cannot be mocked
|
||||
// The form ID generation logic is tested in integration tests
|
||||
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
|
||||
}
|
||||
|
||||
private function createComponent(): LiveComponentContract
|
||||
{
|
||||
return new TestComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
}
|
||||
|
||||
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
|
||||
{
|
||||
return LiveComponentContextHelper::createForAction(
|
||||
container: $this->container,
|
||||
componentClass: ClassName::create($component::class),
|
||||
actionMethod: MethodName::create('testAction'),
|
||||
componentId: $component->id,
|
||||
actionParameters: $params,
|
||||
component: $component
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
require_once __DIR__ . '/TestComponents.php';
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\Attributes\Execution\AttributeExecutionContext;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\LiveComponents\Attributes\RequiresPermission;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
|
||||
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
|
||||
use App\Framework\LiveComponents\Security\Guards\LiveComponentPermissionGuard;
|
||||
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LiveComponentPermissionGuardTest extends TestCase
|
||||
{
|
||||
private LiveComponentPermissionGuard $guard;
|
||||
private ActionAuthorizationChecker&MockObject $authorizationChecker;
|
||||
private DefaultContainer $container;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->authorizationChecker = $this->createMock(ActionAuthorizationChecker::class);
|
||||
$this->container = new DefaultContainer();
|
||||
$this->container->instance(ActionAuthorizationChecker::class, $this->authorizationChecker);
|
||||
|
||||
$this->guard = new LiveComponentPermissionGuard($this->authorizationChecker);
|
||||
}
|
||||
|
||||
public function test_allows_authorized_action(): void
|
||||
{
|
||||
$component = $this->createComponent();
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$context = $this->createContext($component, $params);
|
||||
$permissionAttribute = new RequiresPermission('edit_post');
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('isAuthorized')
|
||||
->with($component, 'testAction', $permissionAttribute)
|
||||
->willReturn(true);
|
||||
|
||||
// Should not throw
|
||||
$this->guard->check($context, $permissionAttribute);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_rejects_unauthorized_action(): void
|
||||
{
|
||||
$component = $this->createComponent();
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$context = $this->createContext($component, $params);
|
||||
$permissionAttribute = new RequiresPermission('edit_post');
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('isAuthorized')
|
||||
->willReturn(false);
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('isAuthenticated')
|
||||
->willReturn(true);
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('getUserPermissions')
|
||||
->willReturn(['view_post']);
|
||||
|
||||
$this->expectException(UnauthorizedActionException::class);
|
||||
|
||||
$this->guard->check($context, $permissionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_unauthenticated_user(): void
|
||||
{
|
||||
$component = $this->createComponent();
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$context = $this->createContext($component, $params);
|
||||
$permissionAttribute = new RequiresPermission('edit_post');
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('isAuthorized')
|
||||
->willReturn(false);
|
||||
|
||||
$this->authorizationChecker
|
||||
->expects($this->once())
|
||||
->method('isAuthenticated')
|
||||
->willReturn(false);
|
||||
|
||||
$this->expectException(UnauthorizedActionException::class);
|
||||
$this->expectExceptionMessage('requires authentication');
|
||||
|
||||
$this->guard->check($context, $permissionAttribute);
|
||||
}
|
||||
|
||||
public function test_rejects_context_without_live_component_data(): void
|
||||
{
|
||||
$context = AttributeExecutionContext::forMethod(
|
||||
container: $this->container,
|
||||
className: ClassName::create('TestComponent'),
|
||||
methodName: MethodName::create('testMethod')
|
||||
);
|
||||
$permissionAttribute = new RequiresPermission('edit_post');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('LiveComponentContextData');
|
||||
|
||||
$this->guard->check($context, $permissionAttribute);
|
||||
}
|
||||
|
||||
private function createComponent(): LiveComponentContract
|
||||
{
|
||||
return new TestComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
}
|
||||
|
||||
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
|
||||
{
|
||||
return LiveComponentContextHelper::createForAction(
|
||||
container: $this->container,
|
||||
componentClass: ClassName::create($component::class),
|
||||
actionMethod: MethodName::create('testAction'),
|
||||
componentId: $component->id,
|
||||
actionParameters: $params,
|
||||
component: $component
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
require_once __DIR__ . '/TestComponents.php';
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\Attributes\Execution\AttributeExecutionContext;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\ClientIdentifier;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\LiveComponents\Attributes\Action;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
|
||||
use App\Framework\LiveComponents\Security\Guards\LiveComponentRateLimitGuard;
|
||||
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
|
||||
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
|
||||
use App\Framework\LiveComponents\Services\RateLimitResult;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\Cache\Driver\InMemoryCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
use App\Framework\RateLimit\RateLimiter;
|
||||
use App\Framework\RateLimit\Storage\CacheStorage;
|
||||
use Tests\Unit\Framework\LiveComponents\Security\TestComponent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LiveComponentRateLimitGuardTest extends TestCase
|
||||
{
|
||||
private LiveComponentRateLimitGuard $guard;
|
||||
private LiveComponentRateLimiter $rateLimiter;
|
||||
private DefaultContainer $container;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Use real instance since LiveComponentRateLimiter is final
|
||||
$this->container = new DefaultContainer();
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$storage = new CacheStorage($cache);
|
||||
$baseRateLimiter = new RateLimiter($storage);
|
||||
$this->container->instance(\App\Framework\RateLimit\RateLimiter::class, $baseRateLimiter);
|
||||
$this->rateLimiter = new LiveComponentRateLimiter($baseRateLimiter);
|
||||
|
||||
$this->guard = new LiveComponentRateLimitGuard($this->rateLimiter);
|
||||
}
|
||||
|
||||
public function test_skips_check_when_no_client_identifier(): void
|
||||
{
|
||||
$component = $this->createComponent();
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$context = $this->createContext($component, $params);
|
||||
$actionAttribute = new Action(rateLimit: 10);
|
||||
|
||||
// Should not throw and not call rate limiter (no client identifier)
|
||||
$this->guard->check($context, $actionAttribute);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_rejects_context_without_live_component_data(): void
|
||||
{
|
||||
$context = AttributeExecutionContext::forMethod(
|
||||
container: $this->container,
|
||||
className: ClassName::create('TestComponent'),
|
||||
methodName: MethodName::create('testMethod')
|
||||
);
|
||||
$actionAttribute = new Action();
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('LiveComponentContextData');
|
||||
|
||||
$this->guard->check($context, $actionAttribute);
|
||||
}
|
||||
|
||||
private function createComponent(): LiveComponentContract
|
||||
{
|
||||
return new TestComponent(
|
||||
ComponentId::create('test', 'demo'),
|
||||
CounterState::empty()
|
||||
);
|
||||
}
|
||||
|
||||
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
|
||||
{
|
||||
return LiveComponentContextHelper::createForAction(
|
||||
container: $this->container,
|
||||
componentClass: ClassName::create($component::class),
|
||||
actionMethod: MethodName::create('testAction'),
|
||||
componentId: $component->id,
|
||||
actionParameters: $params,
|
||||
component: $component
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
|
||||
final class TestComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
}
|
||||
|
||||
127
tests/Unit/Framework/LiveComponents/Security/TestComponents.php
Normal file
127
tests/Unit/Framework/LiveComponents/Security/TestComponents.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\Security;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterState;
|
||||
use App\Framework\LiveComponents\Attributes\Action;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
|
||||
/**
|
||||
* Test Component with valid action
|
||||
*/
|
||||
final class ValidActionComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
public function validAction(): CounterState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Component with private action (should fail validation)
|
||||
*/
|
||||
final class PrivateActionComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
private function privateAction(): CounterState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Component with static action (should fail validation)
|
||||
*/
|
||||
final class StaticActionComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
public static function staticAction(): CounterState
|
||||
{
|
||||
return CounterState::empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Component with primitive return type (should fail validation)
|
||||
*/
|
||||
final class PrimitiveReturnTypeComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
public function intAction(): int
|
||||
{
|
||||
return 42;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Component with array return type (should fail validation)
|
||||
*/
|
||||
final class ArrayReturnTypeComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public CounterState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(templatePath: 'test', data: []);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
public function arrayAction(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
311
tests/Unit/Framework/LiveComponents/UI/UIHelperTest.php
Normal file
311
tests/Unit/Framework/LiveComponents/UI/UIHelperTest.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\LiveComponents\UI;
|
||||
|
||||
use App\Framework\LiveComponents\ComponentEventDispatcher;
|
||||
use App\Framework\LiveComponents\UI\UIHelper;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentEvent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Test class for UIHelper
|
||||
*/
|
||||
final class UIHelperTest extends TestCase
|
||||
{
|
||||
public function testShowToastDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->toast('Test message', 'success', null);
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertInstanceOf(ComponentEvent::class, $event);
|
||||
$this->assertEquals('toast:show', $event->name);
|
||||
$this->assertTrue($event->isBroadcast());
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('Test message', $payload->getString('message'));
|
||||
$this->assertEquals('success', $payload->getString('type'));
|
||||
}
|
||||
|
||||
public function testShowToastWithDefaults(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->toast('Test message');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('info', $payload->getString('type'));
|
||||
$this->assertEquals(5000, $payload->getInt('duration'));
|
||||
$this->assertEquals('top-right', $payload->getString('position'));
|
||||
$this->assertEquals('global', $payload->getString('componentId'));
|
||||
}
|
||||
|
||||
public function testShowToastWithNullEvents(): void
|
||||
{
|
||||
$ui = new UIHelper(null);
|
||||
|
||||
// Should not throw error
|
||||
$ui->toast('Test message');
|
||||
|
||||
$this->assertTrue(true); // Test passes if no exception thrown
|
||||
}
|
||||
|
||||
public function testSuccessToast(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->successToast('Success message');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('success', $payload->getString('type'));
|
||||
$this->assertEquals('Success message', $payload->getString('message'));
|
||||
}
|
||||
|
||||
public function testErrorToast(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->errorToast('Error message');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('error', $payload->getString('type'));
|
||||
$this->assertEquals(0, $payload->getInt('duration')); // Persistent by default
|
||||
}
|
||||
|
||||
public function testHideToastDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->hideToast('test-component');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertEquals('toast:hide', $event->name);
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('test-component', $payload->getString('componentId'));
|
||||
}
|
||||
|
||||
public function testShowModalDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->modal(
|
||||
'test-component',
|
||||
'Test Title',
|
||||
'<p>Test content</p>',
|
||||
\App\Framework\LiveComponents\Events\UI\Options\ModalOptions::create()
|
||||
->withSize(\App\Framework\LiveComponents\Events\UI\Enums\ModalSize::Large)
|
||||
->withButtons([['text' => 'OK', 'class' => 'btn-primary']])
|
||||
->closeOnBackdrop(false)
|
||||
->closeOnEscape(false)
|
||||
);
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertEquals('modal:show', $event->name);
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('test-component', $payload->getString('componentId'));
|
||||
$this->assertEquals('Test Title', $payload->getString('title'));
|
||||
$this->assertEquals('<p>Test content</p>', $payload->getString('content'));
|
||||
$this->assertEquals('large', $payload->getString('size'));
|
||||
$this->assertFalse($payload->getBool('closeOnBackdrop'));
|
||||
$this->assertFalse($payload->getBool('closeOnEscape'));
|
||||
}
|
||||
|
||||
public function testShowModalWithDefaults(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->modal('test-component', 'Title', 'Content');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('medium', $payload->getString('size'));
|
||||
$this->assertTrue($payload->getBool('closeOnBackdrop'));
|
||||
$this->assertTrue($payload->getBool('closeOnEscape'));
|
||||
}
|
||||
|
||||
public function testCloseModalDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->closeModal('test-component');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertEquals('modal:close', $event->name);
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('test-component', $payload->getString('componentId'));
|
||||
}
|
||||
|
||||
public function testShowConfirmDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->confirm(
|
||||
'test-component',
|
||||
'Confirm Title',
|
||||
'Confirm message',
|
||||
\App\Framework\LiveComponents\Events\UI\Options\ConfirmOptions::create()
|
||||
->withButtons('Yes', 'No')
|
||||
->withClasses('btn-danger', 'btn-secondary')
|
||||
);
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertEquals('modal:confirm', $event->name);
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('test-component', $payload->getString('componentId'));
|
||||
$this->assertEquals('Confirm Title', $payload->getString('title'));
|
||||
$this->assertEquals('Confirm message', $payload->getString('message'));
|
||||
$this->assertEquals('Yes', $payload->getString('confirmText'));
|
||||
$this->assertEquals('No', $payload->getString('cancelText'));
|
||||
}
|
||||
|
||||
public function testShowConfirmWithDefaults(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->confirm('test-component', 'Title', 'Message');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('Confirm', $payload->getString('confirmText'));
|
||||
$this->assertEquals('Cancel', $payload->getString('cancelText'));
|
||||
$this->assertEquals('btn-primary', $payload->getString('confirmClass'));
|
||||
$this->assertEquals('btn-secondary', $payload->getString('cancelClass'));
|
||||
}
|
||||
|
||||
public function testConfirmDelete(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->confirmDelete('test-component', 'Item Name', 'deleteAction', ['id' => 123]);
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('Delete Item Name?', $payload->getString('title'));
|
||||
$this->assertEquals("Are you sure you want to delete 'Item Name'? This action cannot be undone.", $payload->getString('message'));
|
||||
$this->assertEquals('Delete', $payload->getString('confirmText'));
|
||||
$this->assertEquals('btn-danger', $payload->getString('confirmClass'));
|
||||
$this->assertEquals('deleteAction', $payload->getString('confirmAction'));
|
||||
$this->assertEquals(['id' => 123], $payload->getArray('confirmParams'));
|
||||
}
|
||||
|
||||
public function testShowAlertDispatchesEvent(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->alert('test-component', 'Alert Title', 'Alert message', 'error', 'OK');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$this->assertCount(1, $dispatchedEvents);
|
||||
|
||||
$event = $dispatchedEvents[0];
|
||||
$this->assertEquals('modal:alert', $event->name);
|
||||
|
||||
$payload = $event->payload;
|
||||
$this->assertEquals('test-component', $payload->getString('componentId'));
|
||||
$this->assertEquals('Alert Title', $payload->getString('title'));
|
||||
$this->assertEquals('Alert message', $payload->getString('message'));
|
||||
$this->assertEquals('error', $payload->getString('type'));
|
||||
$this->assertEquals('OK', $payload->getString('buttonText'));
|
||||
}
|
||||
|
||||
public function testShowAlertWithDefaults(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$ui->alert('test-component', 'Title', 'Message');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('info', $payload->getString('type'));
|
||||
$this->assertEquals('OK', $payload->getString('buttonText'));
|
||||
}
|
||||
|
||||
public function testFluentInterface(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
|
||||
$result = $ui->successToast('Saved!')
|
||||
->infoToast('Processing...')
|
||||
->modal('modal-1', 'Title', 'Content');
|
||||
|
||||
$this->assertSame($ui, $result);
|
||||
$this->assertCount(3, $events->getEvents());
|
||||
}
|
||||
|
||||
public function testComponentIdAsValueObject(): void
|
||||
{
|
||||
$events = new ComponentEventDispatcher();
|
||||
$ui = new UIHelper($events);
|
||||
$componentId = \App\Framework\LiveComponents\ValueObjects\ComponentId::fromString('test:component');
|
||||
|
||||
$ui->modal($componentId, 'Title', 'Content');
|
||||
|
||||
$dispatchedEvents = $events->getEvents();
|
||||
$event = $dispatchedEvents[0];
|
||||
$payload = $event->payload;
|
||||
|
||||
$this->assertEquals('test:component', $payload->getString('componentId'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\Template\Expression;
|
||||
|
||||
use App\Framework\Template\Expression\ExpressionEvaluator;
|
||||
|
||||
describe('ExpressionEvaluator', function () {
|
||||
beforeEach(function () {
|
||||
$this->evaluator = new ExpressionEvaluator();
|
||||
});
|
||||
|
||||
describe('nested array access', function () {
|
||||
it('supports single level array access', function () {
|
||||
$context = [
|
||||
'item' => ['name' => 'Test'],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("item['name']", $context);
|
||||
|
||||
expect($result)->toBe('Test');
|
||||
});
|
||||
|
||||
it('supports nested array access', function () {
|
||||
$context = [
|
||||
'item' => [
|
||||
'user' => [
|
||||
'name' => 'John',
|
||||
'email' => 'john@example.com',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("item['user']['name']", $context);
|
||||
|
||||
expect($result)->toBe('John');
|
||||
});
|
||||
|
||||
it('supports nested array access with $ prefix', function () {
|
||||
$context = [
|
||||
'item' => [
|
||||
'user' => [
|
||||
'name' => 'John',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("\$item['user']['name']", $context);
|
||||
|
||||
expect($result)->toBe('John');
|
||||
});
|
||||
|
||||
it('returns null for missing nested keys', function () {
|
||||
$context = [
|
||||
'item' => ['name' => 'Test'],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("item['user']['name']", $context);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('supports numeric indices in nested arrays', function () {
|
||||
$context = [
|
||||
'items' => [
|
||||
[0 => ['name' => 'First']],
|
||||
[0 => ['name' => 'Second']],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("items[0][0]['name']", $context);
|
||||
|
||||
expect($result)->toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback mechanism', function () {
|
||||
it('supports null coalescing operator', function () {
|
||||
$context = [
|
||||
'name' => null,
|
||||
];
|
||||
|
||||
// Note: This is handled by ForTransformer/PlaceholderTransformer, not ExpressionEvaluator
|
||||
// ExpressionEvaluator just evaluates the expression
|
||||
$result = $this->evaluator->evaluate("name", $context);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', function () {
|
||||
it('returns null for missing variables', function () {
|
||||
$context = [];
|
||||
|
||||
$result = $this->evaluator->evaluate('missingVar', $context);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing array keys', function () {
|
||||
$context = [
|
||||
'item' => ['name' => 'Test'],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate("item['missing']", $context);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles invalid expressions gracefully', function () {
|
||||
$context = [];
|
||||
|
||||
$result = $this->evaluator->evaluate('invalid[expression', $context);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dot notation', function () {
|
||||
it('supports dot notation for nested properties', function () {
|
||||
$context = [
|
||||
'user' => [
|
||||
'profile' => [
|
||||
'name' => 'John',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate('user.profile.name', $context);
|
||||
|
||||
expect($result)->toBe('John');
|
||||
});
|
||||
|
||||
it('supports dot notation with numeric indices', function () {
|
||||
$context = [
|
||||
'items' => [
|
||||
['name' => 'First'],
|
||||
['name' => 'Second'],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate('items.0.name', $context);
|
||||
|
||||
expect($result)->toBe('First');
|
||||
});
|
||||
|
||||
it('supports dot notation with $ prefix for arrays', function () {
|
||||
$context = [
|
||||
'option' => [
|
||||
'id' => 'landing_page',
|
||||
'name' => 'Landing Page',
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate('$option.id', $context);
|
||||
|
||||
expect($result)->toBe('landing_page');
|
||||
});
|
||||
|
||||
it('supports dot notation with $ prefix for nested arrays', function () {
|
||||
$context = [
|
||||
'option' => [
|
||||
'user' => [
|
||||
'name' => 'John',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->evaluator->evaluate('$option.user.name', $context);
|
||||
|
||||
expect($result)->toBe('John');
|
||||
});
|
||||
|
||||
it('supports both array notation and dot notation', function () {
|
||||
$context = [
|
||||
'option' => [
|
||||
'id' => 'test',
|
||||
'name' => 'Test',
|
||||
],
|
||||
];
|
||||
|
||||
$arrayResult = $this->evaluator->evaluate("option['id']", $context);
|
||||
$dotResult = $this->evaluator->evaluate('option.id', $context);
|
||||
$dollarDotResult = $this->evaluator->evaluate('$option.id', $context);
|
||||
|
||||
expect($arrayResult)->toBe('test');
|
||||
expect($dotResult)->toBe('test');
|
||||
expect($dollarDotResult)->toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\View\Dom\Parser\HtmlParser;
|
||||
use App\Framework\View\Dom\Renderer\HtmlRenderer;
|
||||
use App\Framework\View\Dom\Transformer\PlaceholderTransformer;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\Meta\MetaData;
|
||||
|
||||
describe('PlaceholderTransformer', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->parser = new HtmlParser();
|
||||
$this->renderer = new HtmlRenderer();
|
||||
$this->transformer = new PlaceholderTransformer($this->container);
|
||||
});
|
||||
|
||||
describe('fallback mechanism', function () {
|
||||
it('supports null coalescing operator', function () {
|
||||
$html = '<div>{{ $name ?? "Default Name" }}</div>';
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: []
|
||||
);
|
||||
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->transformer->transform($document, $context);
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
expect($output)->toContain('Default Name');
|
||||
});
|
||||
|
||||
it('uses actual value when variable exists', function () {
|
||||
$html = '<div>{{ $name ?? "Default Name" }}</div>';
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: ['name' => 'Actual Name']
|
||||
);
|
||||
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->transformer->transform($document, $context);
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
expect($output)->toContain('Actual Name');
|
||||
expect($output)->not->toContain('Default Name');
|
||||
});
|
||||
|
||||
it('handles null values with fallback', function () {
|
||||
$html = '<div>{{ $name ?? "Default" }}</div>';
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: ['name' => null]
|
||||
);
|
||||
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->transformer->transform($document, $context);
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
expect($output)->toContain('Default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested array access', function () {
|
||||
it('supports nested array access in placeholders', function () {
|
||||
$html = '<div>{{ $item["user"]["name"] }}</div>';
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'item' => [
|
||||
'user' => [
|
||||
'name' => 'John',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->transformer->transform($document, $context);
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
expect($output)->toContain('John');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', function () {
|
||||
it('returns empty string for missing variables in production', function () {
|
||||
putenv('APP_DEBUG=false');
|
||||
$html = '<div>{{ $missing }}</div>';
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: []
|
||||
);
|
||||
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->transformer->transform($document, $context);
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Should not throw, but return empty string
|
||||
expect($output)->toContain('<div></div>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\Config\AppConfig;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
use App\Framework\LiveComponents\Performance\ComponentMetadataCache;
|
||||
use App\Framework\LiveComponents\Performance\ComponentMetadataCacheInterface;
|
||||
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\View\Components\Button;
|
||||
use App\Framework\View\Dom\Parser\HtmlParser;
|
||||
use App\Framework\View\Dom\Renderer\HtmlRenderer;
|
||||
use App\Framework\View\Dom\Transformer\ForTransformer;
|
||||
use App\Framework\View\Dom\Transformer\PlaceholderTransformer;
|
||||
use App\Framework\View\Dom\Transformer\XComponentTransformer;
|
||||
use App\Framework\View\Processing\AstProcessingPipeline;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\StaticComponentRenderer;
|
||||
|
||||
describe('XComponentTransformer + ForTransformer Integration', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->parser = new HtmlParser();
|
||||
$this->renderer = new HtmlRenderer();
|
||||
|
||||
// Create component registry and dependencies
|
||||
$discoveryRegistry = $this->container->get('App\Framework\Discovery\Results\DiscoveryRegistry');
|
||||
$liveComponentRenderer = $this->container->get('App\Framework\View\LiveComponentRenderer');
|
||||
$cacheManager = $this->container->get('App\Framework\LiveComponents\Cache\ComponentCacheManager');
|
||||
$handler = $this->container->get('App\Framework\LiveComponents\LiveComponentHandler');
|
||||
$metadataCache = new ComponentMetadataCache($this->container);
|
||||
$performanceTracker = $this->container->get('App\Framework\LiveComponents\Performance\NestedPerformanceTracker');
|
||||
|
||||
$this->componentRegistry = new ComponentRegistry(
|
||||
$this->container,
|
||||
$discoveryRegistry,
|
||||
$liveComponentRenderer,
|
||||
$cacheManager,
|
||||
$handler,
|
||||
$metadataCache,
|
||||
$performanceTracker
|
||||
);
|
||||
|
||||
$this->staticComponentRenderer = new StaticComponentRenderer();
|
||||
$this->metadataCache = $metadataCache;
|
||||
$this->appConfig = new AppConfig(['debug' => true]);
|
||||
|
||||
// Create transformers
|
||||
$this->forTransformer = new ForTransformer($this->container);
|
||||
$this->placeholderTransformer = new PlaceholderTransformer($this->container);
|
||||
$this->xComponentTransformer = new XComponentTransformer(
|
||||
$this->componentRegistry,
|
||||
$this->staticComponentRenderer,
|
||||
$this->metadataCache,
|
||||
$this->parser,
|
||||
$this->appConfig
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms x-button components within for loops', function () {
|
||||
$html = <<<HTML
|
||||
<ul>
|
||||
<for items="{{pages}}" as="page">
|
||||
<li>
|
||||
<x-button variant="secondary" size="sm" href="{{page.url}}" class="page-link">{{page.number}}</x-button>
|
||||
</li>
|
||||
</for>
|
||||
</ul>
|
||||
HTML;
|
||||
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'pages' => [
|
||||
['number' => 1, 'url' => '/page/1'],
|
||||
['number' => 2, 'url' => '/page/2'],
|
||||
['number' => 3, 'url' => '/page/3'],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
// Process through pipeline: ForTransformer -> PlaceholderTransformer -> XComponentTransformer
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->forTransformer->transform($document, $context);
|
||||
$document = $this->placeholderTransformer->transform($document, $context);
|
||||
$document = $this->xComponentTransformer->transform($document, $context);
|
||||
|
||||
// Render to HTML
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Assert: No x-button tags should remain
|
||||
expect($output)->not->toContain('<x-button');
|
||||
expect($output)->not->toContain('</x-button>');
|
||||
|
||||
// Assert: Buttons should be rendered as <a> or <button> elements
|
||||
expect($output)->toContain('<a');
|
||||
expect($output)->toContain('href="/page/1"');
|
||||
expect($output)->toContain('href="/page/2"');
|
||||
expect($output)->toContain('href="/page/3"');
|
||||
expect($output)->toContain('1');
|
||||
expect($output)->toContain('2');
|
||||
expect($output)->toContain('3');
|
||||
});
|
||||
|
||||
it('transforms x-button components in pagination-like structure', function () {
|
||||
$html = <<<HTML
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
<li if="{{has_previous}}">
|
||||
<x-button variant="secondary" size="sm" href="{{previous_url}}" class="page-link">Previous</x-button>
|
||||
</li>
|
||||
<for items="{{pages}}" as="page">
|
||||
<li if="{{!page.ellipsis}}">
|
||||
<x-button variant="{{page.active ? 'primary' : 'secondary'}}" size="sm" href="{{page.url}}" class="page-link">{{page.number}}</x-button>
|
||||
</li>
|
||||
</for>
|
||||
<li if="{{has_next}}">
|
||||
<x-button variant="secondary" size="sm" href="{{next_url}}" class="page-link">Next</x-button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
HTML;
|
||||
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'has_previous' => true,
|
||||
'has_next' => true,
|
||||
'previous_url' => '/page/1',
|
||||
'next_url' => '/page/3',
|
||||
'pages' => [
|
||||
['number' => 1, 'url' => '/page/1', 'active' => true, 'ellipsis' => false],
|
||||
['number' => 2, 'url' => '/page/2', 'active' => false, 'ellipsis' => false],
|
||||
['number' => 3, 'url' => '/page/3', 'active' => false, 'ellipsis' => false],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
// Process through pipeline
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->forTransformer->transform($document, $context);
|
||||
$document = $this->placeholderTransformer->transform($document, $context);
|
||||
$document = $this->xComponentTransformer->transform($document, $context);
|
||||
|
||||
// Render to HTML
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Assert: No x-button tags should remain
|
||||
expect($output)->not->toContain('<x-button');
|
||||
expect($output)->not->toContain('</x-button>');
|
||||
|
||||
// Assert: All buttons should be rendered
|
||||
expect($output)->toContain('Previous');
|
||||
expect($output)->toContain('Next');
|
||||
expect($output)->toContain('href="/page/1"');
|
||||
expect($output)->toContain('href="/page/2"');
|
||||
expect($output)->toContain('href="/page/3"');
|
||||
});
|
||||
|
||||
it('handles nested for loops with x-button components', function () {
|
||||
$html = <<<HTML
|
||||
<div>
|
||||
<for items="{{sections}}" as="section">
|
||||
<h2>{{section.title}}</h2>
|
||||
<ul>
|
||||
<for items="{{section.items}}" as="item">
|
||||
<li>
|
||||
<x-button variant="secondary" href="{{item.url}}">{{item.name}}</x-button>
|
||||
</li>
|
||||
</for>
|
||||
</ul>
|
||||
</for>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Section 1',
|
||||
'items' => [
|
||||
['name' => 'Item 1', 'url' => '/item/1'],
|
||||
['name' => 'Item 2', 'url' => '/item/2'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Section 2',
|
||||
'items' => [
|
||||
['name' => 'Item 3', 'url' => '/item/3'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
// Process through pipeline
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->forTransformer->transform($document, $context);
|
||||
$document = $this->placeholderTransformer->transform($document, $context);
|
||||
$document = $this->xComponentTransformer->transform($document, $context);
|
||||
|
||||
// Render to HTML
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Assert: No x-button tags should remain
|
||||
expect($output)->not->toContain('<x-button');
|
||||
expect($output)->not->toContain('</x-button>');
|
||||
|
||||
// Assert: All buttons should be rendered
|
||||
expect($output)->toContain('Item 1');
|
||||
expect($output)->toContain('Item 2');
|
||||
expect($output)->toContain('Item 3');
|
||||
expect($output)->toContain('href="/item/1"');
|
||||
expect($output)->toContain('href="/item/2"');
|
||||
expect($output)->toContain('href="/item/3"');
|
||||
});
|
||||
|
||||
it('handles empty for loops gracefully', function () {
|
||||
$html = <<<HTML
|
||||
<ul>
|
||||
<for items="{{pages}}" as="page">
|
||||
<li>
|
||||
<x-button variant="secondary" href="{{page.url}}">{{page.number}}</x-button>
|
||||
</li>
|
||||
</for>
|
||||
</ul>
|
||||
HTML;
|
||||
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'pages' => [],
|
||||
]
|
||||
);
|
||||
|
||||
// Process through pipeline
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->forTransformer->transform($document, $context);
|
||||
$document = $this->placeholderTransformer->transform($document, $context);
|
||||
$document = $this->xComponentTransformer->transform($document, $context);
|
||||
|
||||
// Render to HTML
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Assert: No x-button tags should remain
|
||||
expect($output)->not->toContain('<x-button');
|
||||
expect($output)->not->toContain('</x-button>');
|
||||
expect($output)->not->toContain('<for');
|
||||
});
|
||||
|
||||
it('processes x-button components outside for loops correctly', function () {
|
||||
$html = <<<HTML
|
||||
<div>
|
||||
<x-button variant="primary" href="/outside">Outside Button</x-button>
|
||||
<ul>
|
||||
<for items="{{pages}}" as="page">
|
||||
<li>
|
||||
<x-button variant="secondary" href="{{page.url}}">{{page.number}}</x-button>
|
||||
</li>
|
||||
</for>
|
||||
</ul>
|
||||
<x-button variant="primary" href="/after">After Button</x-button>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
$context = new RenderContext(
|
||||
template: 'test',
|
||||
metaData: new MetaData('Test'),
|
||||
data: [
|
||||
'pages' => [
|
||||
['number' => 1, 'url' => '/page/1'],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
// Process through pipeline
|
||||
$document = $this->parser->parse($html);
|
||||
$document = $this->forTransformer->transform($document, $context);
|
||||
$document = $this->placeholderTransformer->transform($document, $context);
|
||||
$document = $this->xComponentTransformer->transform($document, $context);
|
||||
|
||||
// Render to HTML
|
||||
$output = $this->renderer->render($document);
|
||||
|
||||
// Assert: No x-button tags should remain
|
||||
expect($output)->not->toContain('<x-button');
|
||||
expect($output)->not->toContain('</x-button>');
|
||||
|
||||
// Assert: All buttons should be rendered
|
||||
expect($output)->toContain('Outside Button');
|
||||
expect($output)->toContain('After Button');
|
||||
expect($output)->toContain('href="/outside"');
|
||||
expect($output)->toContain('href="/after"');
|
||||
expect($output)->toContain('href="/page/1"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Http\Session\SessionManager;
|
||||
use App\Framework\View\Response\FormDataResponseProcessor;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->formIdGenerator = new FormIdGenerator();
|
||||
$this->sessionManager = Mockery::mock(SessionManager::class);
|
||||
|
||||
$this->processor = new FormDataResponseProcessor(
|
||||
$this->formIdGenerator,
|
||||
$this->sessionManager
|
||||
);
|
||||
|
||||
// Mock session
|
||||
$this->session = Mockery::mock(SessionInterface::class);
|
||||
$this->csrfProtection = Mockery::mock();
|
||||
|
||||
$this->session->shouldReceive('csrf')->andReturn($this->csrfProtection);
|
||||
$this->sessionManager->shouldReceive('saveSessionData')->andReturnNull();
|
||||
});
|
||||
|
||||
it('replaces token placeholder with DOM processing', function () {
|
||||
$formId = 'form_abc123def456';
|
||||
$token = str_repeat('a', 64);
|
||||
|
||||
$html = <<<HTML
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
|
||||
|
||||
$result = $this->processor->process($html, $this->session);
|
||||
|
||||
expect($result)->toContain($token);
|
||||
expect($result)->not->toContain("___TOKEN_{$formId}___");
|
||||
});
|
||||
|
||||
it('handles token placeholder without quotes', function () {
|
||||
$formId = 'form_abc123def456';
|
||||
$token = str_repeat('b', 64);
|
||||
|
||||
$html = <<<HTML
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value=___TOKEN_{$formId}___>
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
|
||||
|
||||
$result = $this->processor->process($html, $this->session);
|
||||
|
||||
expect($result)->toContain('value="' . $token . '"');
|
||||
expect($result)->not->toContain("___TOKEN_{$formId}___");
|
||||
});
|
||||
|
||||
it('falls back to regex when DOM processing fails', function () {
|
||||
$formId = 'form_abc123def456';
|
||||
$token = str_repeat('c', 64);
|
||||
|
||||
// Malformed HTML that might cause DOM parsing issues
|
||||
$html = <<<HTML
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
<unclosed-tag>
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
|
||||
|
||||
// Should not throw exception, should fall back to regex
|
||||
$result = $this->processor->process($html, $this->session);
|
||||
|
||||
// Should still replace token (via regex fallback)
|
||||
expect($result)->toContain($token);
|
||||
});
|
||||
|
||||
it('processes multiple forms independently', function () {
|
||||
$formId1 = 'form_abc123def456';
|
||||
$formId2 = 'form_xyz789ghi012';
|
||||
$token1 = str_repeat('d', 64);
|
||||
$token2 = str_repeat('e', 64);
|
||||
|
||||
$html = <<<HTML
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId1}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId1}___">
|
||||
</form>
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId2}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId2}___">
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId1)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token1));
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId2)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token2));
|
||||
|
||||
$result = $this->processor->process($html, $this->session);
|
||||
|
||||
expect($result)->toContain($token1);
|
||||
expect($result)->toContain($token2);
|
||||
expect($result)->not->toContain("___TOKEN_{$formId1}___");
|
||||
expect($result)->not->toContain("___TOKEN_{$formId2}___");
|
||||
});
|
||||
|
||||
it('validates token length after replacement', function () {
|
||||
$formId = 'form_abc123def456';
|
||||
$token = str_repeat('f', 64);
|
||||
|
||||
$html = <<<HTML
|
||||
<form>
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
$this->csrfProtection->shouldReceive('generateToken')
|
||||
->with($formId)
|
||||
->once()
|
||||
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
|
||||
|
||||
$result = $this->processor->process($html, $this->session);
|
||||
|
||||
// Extract token from result
|
||||
preg_match('/name="_token"[^>]*value="([^"]+)"/', $result, $matches);
|
||||
|
||||
if (isset($matches[1])) {
|
||||
expect(strlen($matches[1]))->toBe(64);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user