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:
142
tests/Feature/Framework/Http/Session/CsrfAtomicUpdateTest.php
Normal file
142
tests/Feature/Framework/Http/Session/CsrfAtomicUpdateTest.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Http\Session\FileSessionStorage;
|
||||
use App\Framework\Http\Session\Session;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
use App\Framework\Http\Session\SessionIdGenerator;
|
||||
use App\Framework\Http\Session\SessionManager;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
use App\Framework\Security\CsrfTokenGenerator;
|
||||
use App\Framework\Http\Cookies\SessionCookieConfig;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tempDir = sys_get_temp_dir() . '/php_sessions_test_' . uniqid();
|
||||
mkdir($this->tempDir, 0700, true);
|
||||
|
||||
$this->clock = new SystemClock();
|
||||
$this->storage = new FileSessionStorage($this->tempDir, $this->clock);
|
||||
$this->sessionIdGenerator = new SessionIdGenerator(new SecureRandomGenerator());
|
||||
$this->csrfTokenGenerator = new CsrfTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
$this->cookieConfig = new SessionCookieConfig(
|
||||
name: 'test_session',
|
||||
lifetime: 3600,
|
||||
path: '/',
|
||||
domain: null,
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
sameSite: \App\Framework\Http\Cookies\SameSite::LAX
|
||||
);
|
||||
|
||||
$this->sessionManager = new SessionManager(
|
||||
generator: $this->sessionIdGenerator,
|
||||
responseManipulator: Mockery::mock(\App\Framework\Http\ResponseManipulator::class),
|
||||
clock: $this->clock,
|
||||
csrfTokenGenerator: $this->csrfTokenGenerator,
|
||||
storage: $this->storage,
|
||||
cookieConfig: $this->cookieConfig
|
||||
);
|
||||
|
||||
$this->sessionId = $this->sessionIdGenerator->generate();
|
||||
$this->session = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, []);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (isset($this->tempDir) && is_dir($this->tempDir)) {
|
||||
array_map('unlink', glob($this->tempDir . '/*'));
|
||||
rmdir($this->tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles concurrent token generation atomically', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
// Simulate concurrent token generation
|
||||
$tokens = [];
|
||||
$updates = [];
|
||||
|
||||
// Generate tokens "concurrently" (sequentially in test, but with atomic updates)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$token = $this->session->csrf->generateToken($formId);
|
||||
$tokens[] = $token->toString();
|
||||
|
||||
// Save session after each token generation
|
||||
$this->sessionManager->saveSessionData($this->session);
|
||||
|
||||
// Reload session to simulate new request
|
||||
$data = $this->storage->read($this->sessionId);
|
||||
$this->session = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $data);
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
$uniqueTokens = array_unique($tokens);
|
||||
expect(count($uniqueTokens))->toBe(count($tokens));
|
||||
|
||||
// Should have max 3 tokens (cleanup)
|
||||
$count = $this->session->csrf->getActiveTokenCount($formId);
|
||||
expect($count)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('validates tokens correctly after atomic updates', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
// Generate token
|
||||
$token = $this->session->csrf->generateToken($formId);
|
||||
$this->sessionManager->saveSessionData($this->session);
|
||||
|
||||
// Reload session
|
||||
$data = $this->storage->read($this->sessionId);
|
||||
$this->session = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $data);
|
||||
|
||||
// Validate token
|
||||
$result = $this->session->csrf->validateTokenWithDebug($formId, $token);
|
||||
expect($result['valid'])->toBeTrue();
|
||||
|
||||
// Mark as used and save
|
||||
$this->sessionManager->saveSessionData($this->session);
|
||||
|
||||
// Reload again
|
||||
$data = $this->storage->read($this->sessionId);
|
||||
$this->session = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $data);
|
||||
|
||||
// Should still be valid within resubmit window
|
||||
$result2 = $this->session->csrf->validateTokenWithDebug($formId, $token);
|
||||
expect($result2['valid'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles version conflicts with optimistic locking', function () {
|
||||
$formId = 'test-form';
|
||||
|
||||
// Generate token and save
|
||||
$token1 = $this->session->csrf->generateToken($formId);
|
||||
$this->sessionManager->saveSessionData($this->session);
|
||||
|
||||
// Read session data
|
||||
$data1 = $this->storage->read($this->sessionId);
|
||||
$session1 = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $data1);
|
||||
|
||||
// Read again (simulating concurrent request)
|
||||
$data2 = $this->storage->read($this->sessionId);
|
||||
$session2 = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $data2);
|
||||
|
||||
// Generate tokens in both "requests"
|
||||
$token2 = $session1->csrf->generateToken($formId);
|
||||
$token3 = $session2->csrf->generateToken($formId);
|
||||
|
||||
// Save both (simulating concurrent writes)
|
||||
$this->sessionManager->saveSessionData($session1);
|
||||
$this->sessionManager->saveSessionData($session2);
|
||||
|
||||
// Final read
|
||||
$finalData = $this->storage->read($this->sessionId);
|
||||
$finalSession = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, $finalData);
|
||||
|
||||
// Should have valid tokens
|
||||
$count = $finalSession->csrf->getActiveTokenCount($formId);
|
||||
expect($count)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterComponent;
|
||||
use App\Application\LiveComponents\Dashboard\FailedJobsListComponent;
|
||||
use App\Application\LiveComponents\Dashboard\PerformanceMetricsComponent;
|
||||
use App\Application\LiveComponents\UserStatsComponent;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\LiveComponents\Contracts\Cacheable;
|
||||
use App\Framework\LiveComponents\Contracts\LifecycleAware;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Contracts\Pollable;
|
||||
use App\Framework\LiveComponents\Contracts\SupportsFileUpload;
|
||||
use App\Framework\LiveComponents\Contracts\SupportsSlots;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentState;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
|
||||
describe('Contract Compliance Tests', function () {
|
||||
describe('Pollable Interface', function () {
|
||||
it('verifies Pollable components implement required methods', function () {
|
||||
$component = new PerformanceMetricsComponent(
|
||||
ComponentId::create('performance-metrics', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
expect($component)->toBeInstanceOf(Pollable::class);
|
||||
expect($component)->toBeInstanceOf(LiveComponentContract::class);
|
||||
|
||||
// Check required methods exist
|
||||
$reflection = new ReflectionClass($component);
|
||||
expect($reflection->hasMethod('poll'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getPollInterval'))->toBeTrue();
|
||||
|
||||
// Check method signatures
|
||||
$pollMethod = $reflection->getMethod('poll');
|
||||
expect($pollMethod->isPublic())->toBeTrue();
|
||||
expect($pollMethod->getReturnType()?->getName())->toBe('App\Application\LiveComponents\LiveComponentState');
|
||||
|
||||
$intervalMethod = $reflection->getMethod('getPollInterval');
|
||||
expect($intervalMethod->isPublic())->toBeTrue();
|
||||
expect($intervalMethod->getReturnType()?->getName())->toBe('int');
|
||||
});
|
||||
|
||||
it('verifies poll() returns LiveComponentState', function () {
|
||||
$component = new PerformanceMetricsComponent(
|
||||
ComponentId::create('performance-metrics', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
$result = $component->poll();
|
||||
expect($result)->toBeInstanceOf(\App\Application\LiveComponents\LiveComponentState::class);
|
||||
});
|
||||
|
||||
it('verifies getPollInterval() returns positive integer', function () {
|
||||
$component = new PerformanceMetricsComponent(
|
||||
ComponentId::create('performance-metrics', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
$interval = $component->getPollInterval();
|
||||
expect($interval)->toBeInt();
|
||||
expect($interval)->toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cacheable Interface', function () {
|
||||
it('verifies Cacheable components implement required methods', function () {
|
||||
$component = new UserStatsComponent(
|
||||
ComponentId::create('user-stats', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
expect($component)->toBeInstanceOf(Cacheable::class);
|
||||
expect($component)->toBeInstanceOf(LiveComponentContract::class);
|
||||
|
||||
$reflection = new ReflectionClass($component);
|
||||
|
||||
// Check required methods
|
||||
expect($reflection->hasMethod('getCacheKey'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getCacheTTL'))->toBeTrue();
|
||||
expect($reflection->hasMethod('shouldCache'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getCacheTags'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getVaryBy'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getStaleWhileRevalidate'))->toBeTrue();
|
||||
|
||||
// Check return types
|
||||
$ttlMethod = $reflection->getMethod('getCacheTTL');
|
||||
expect($ttlMethod->getReturnType()?->getName())->toBe(Duration::class);
|
||||
|
||||
$varyByMethod = $reflection->getMethod('getVaryBy');
|
||||
$varyByReturnType = $varyByMethod->getReturnType();
|
||||
expect($varyByReturnType?->allowsNull())->toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies getCacheKey() returns string', function () {
|
||||
$component = new UserStatsComponent(
|
||||
ComponentId::create('user-stats', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
$key = $component->getCacheKey();
|
||||
expect($key)->toBeString();
|
||||
expect($key)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('verifies getCacheTTL() returns Duration', function () {
|
||||
$component = new UserStatsComponent(
|
||||
ComponentId::create('user-stats', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
$ttl = $component->getCacheTTL();
|
||||
expect($ttl)->toBeInstanceOf(Duration::class);
|
||||
});
|
||||
|
||||
it('verifies getCacheTags() returns array', function () {
|
||||
$component = new UserStatsComponent(
|
||||
ComponentId::create('user-stats', 'test'),
|
||||
ComponentState::empty()
|
||||
);
|
||||
|
||||
$tags = $component->getCacheTags();
|
||||
expect($tags)->toBeArray();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LifecycleAware Interface', function () {
|
||||
it('verifies LifecycleAware components implement required methods', function () {
|
||||
// Find a component that implements LifecycleAware
|
||||
$componentClass = CounterComponent::class;
|
||||
$reflection = new ReflectionClass($componentClass);
|
||||
|
||||
if ($reflection->implementsInterface(LifecycleAware::class)) {
|
||||
$component = new $componentClass(
|
||||
ComponentId::create('counter', 'test'),
|
||||
\App\Application\LiveComponents\Counter\CounterState::empty()
|
||||
);
|
||||
|
||||
expect($component)->toBeInstanceOf(LifecycleAware::class);
|
||||
|
||||
// Check required methods
|
||||
expect($reflection->hasMethod('onMount'))->toBeTrue();
|
||||
expect($reflection->hasMethod('onUpdate'))->toBeTrue();
|
||||
expect($reflection->hasMethod('onDestroy'))->toBeTrue();
|
||||
|
||||
// Check method signatures (all should return void)
|
||||
$onMountMethod = $reflection->getMethod('onMount');
|
||||
expect($onMountMethod->getReturnType()?->getName())->toBe('void');
|
||||
} else {
|
||||
// Skip if no component implements LifecycleAware in test scope
|
||||
$this->markTestSkipped('No LifecycleAware component found for testing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SupportsFileUpload Interface', function () {
|
||||
it('verifies SupportsFileUpload components implement required methods', function () {
|
||||
// Find a component that implements SupportsFileUpload
|
||||
$componentClass = \App\Application\LiveComponents\ImageUploader\ImageUploaderComponent::class;
|
||||
|
||||
if (!class_exists($componentClass)) {
|
||||
$this->markTestSkipped('ImageUploaderComponent not available');
|
||||
return;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($componentClass);
|
||||
|
||||
if ($reflection->implementsInterface(SupportsFileUpload::class)) {
|
||||
expect($reflection->hasMethod('handleUpload'))->toBeTrue();
|
||||
expect($reflection->hasMethod('validateUpload'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getAllowedMimeTypes'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getMaxFileSize'))->toBeTrue();
|
||||
|
||||
// Check handleUpload signature
|
||||
$handleUploadMethod = $reflection->getMethod('handleUpload');
|
||||
$params = $handleUploadMethod->getParameters();
|
||||
expect(count($params))->toBeGreaterThanOrEqual(1);
|
||||
expect($params[0]->getType()?->getName())->toBe(\App\Framework\Http\UploadedFile::class);
|
||||
} else {
|
||||
$this->markTestSkipped('No SupportsFileUpload component found for testing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SupportsSlots Interface', function () {
|
||||
it('verifies SupportsSlots components implement required methods', function () {
|
||||
// Find a component that implements SupportsSlots
|
||||
$componentClass = \App\Application\LiveComponents\LayoutComponent::class;
|
||||
|
||||
if (!class_exists($componentClass)) {
|
||||
$this->markTestSkipped('LayoutComponent not available');
|
||||
return;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($componentClass);
|
||||
|
||||
if ($reflection->implementsInterface(SupportsSlots::class)) {
|
||||
expect($reflection->hasMethod('getSlotDefinitions'))->toBeTrue();
|
||||
expect($reflection->hasMethod('processSlotContent'))->toBeTrue();
|
||||
expect($reflection->hasMethod('getSlotContext'))->toBeTrue();
|
||||
} else {
|
||||
$this->markTestSkipped('No SupportsSlots component found for testing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('LiveComponentContract Interface', function () {
|
||||
it('verifies all components implement LiveComponentContract', function () {
|
||||
$components = [
|
||||
CounterComponent::class,
|
||||
PerformanceMetricsComponent::class,
|
||||
UserStatsComponent::class,
|
||||
FailedJobsListComponent::class,
|
||||
];
|
||||
|
||||
foreach ($components as $componentClass) {
|
||||
$reflection = new ReflectionClass($componentClass);
|
||||
expect($reflection->implementsInterface(LiveComponentContract::class))->toBeTrue(
|
||||
"Component {$componentClass} must implement LiveComponentContract"
|
||||
);
|
||||
|
||||
// Check required properties
|
||||
expect($reflection->hasProperty('id'))->toBeTrue();
|
||||
expect($reflection->hasProperty('state'))->toBeTrue();
|
||||
|
||||
// Check required methods
|
||||
expect($reflection->hasMethod('getRenderData'))->toBeTrue();
|
||||
|
||||
$getRenderDataMethod = $reflection->getMethod('getRenderData');
|
||||
expect($getRenderDataMethod->isPublic())->toBeTrue();
|
||||
expect($getRenderDataMethod->getReturnType()?->getName())->toBe(
|
||||
\App\Framework\LiveComponents\ValueObjects\ComponentRenderData::class
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interface Method Signatures', function () {
|
||||
it('verifies Pollable::poll() signature', function () {
|
||||
$reflection = new ReflectionClass(Pollable::class);
|
||||
$pollMethod = $reflection->getMethod('poll');
|
||||
|
||||
expect($pollMethod->getReturnType()?->getName())->toBe(\App\Application\LiveComponents\LiveComponentState::class);
|
||||
expect($pollMethod->getParameters())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('verifies Cacheable::getCacheTTL() signature', function () {
|
||||
$reflection = new ReflectionClass(Cacheable::class);
|
||||
$ttlMethod = $reflection->getMethod('getCacheTTL');
|
||||
|
||||
expect($ttlMethod->getReturnType()?->getName())->toBe(Duration::class);
|
||||
expect($ttlMethod->getParameters())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('verifies SupportsFileUpload::handleUpload() signature', function () {
|
||||
$reflection = new ReflectionClass(SupportsFileUpload::class);
|
||||
$handleUploadMethod = $reflection->getMethod('handleUpload');
|
||||
|
||||
$params = $handleUploadMethod->getParameters();
|
||||
expect(count($params))->toBeGreaterThanOrEqual(1);
|
||||
expect($params[0]->getType()?->getName())->toBe(\App\Framework\Http\UploadedFile::class);
|
||||
expect($handleUploadMethod->getReturnType()?->getName())->toBe(\App\Application\LiveComponents\LiveComponentState::class);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\E2E;
|
||||
|
||||
use App\Framework\LiveComponents\Batch\BatchProcessor;
|
||||
use App\Framework\LiveComponents\Batch\BatchRequest;
|
||||
use App\Framework\LiveComponents\ValueObjects\BatchOperation;
|
||||
use Tests\Feature\Framework\LiveComponents\TestHarness\LiveComponentTestCase;
|
||||
|
||||
/**
|
||||
* E2E Tests for Batch Operations
|
||||
*
|
||||
* Tests batch request processing end-to-end.
|
||||
*/
|
||||
class BatchOperationsE2ETest extends LiveComponentTestCase
|
||||
{
|
||||
private BatchProcessor $batchProcessor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->batchProcessor = $this->getContainer()->get(BatchProcessor::class);
|
||||
}
|
||||
|
||||
public function test_processes_multiple_operations_in_batch(): void
|
||||
{
|
||||
// Create batch request with multiple operations
|
||||
$operations = [
|
||||
BatchOperation::fromArray([
|
||||
'componentId' => 'counter:test1',
|
||||
'method' => 'increment',
|
||||
'params' => ['amount' => 1],
|
||||
]),
|
||||
BatchOperation::fromArray([
|
||||
'componentId' => 'counter:test2',
|
||||
'method' => 'increment',
|
||||
'params' => ['amount' => 2],
|
||||
]),
|
||||
];
|
||||
|
||||
$batchRequest = new BatchRequest(...$operations);
|
||||
$response = $this->batchProcessor->process($batchRequest);
|
||||
|
||||
$this->assertEquals(2, $response->totalOperations);
|
||||
$this->assertEquals(2, $response->successCount);
|
||||
$this->assertEquals(0, $response->failureCount);
|
||||
$this->assertTrue($response->isFullSuccess());
|
||||
}
|
||||
|
||||
public function test_handles_partial_failures_gracefully(): void
|
||||
{
|
||||
// Create batch with one valid and one invalid operation
|
||||
$operations = [
|
||||
BatchOperation::fromArray([
|
||||
'componentId' => 'counter:test1',
|
||||
'method' => 'increment',
|
||||
'params' => ['amount' => 1],
|
||||
]),
|
||||
BatchOperation::fromArray([
|
||||
'componentId' => 'nonexistent:test',
|
||||
'method' => 'increment',
|
||||
'params' => [],
|
||||
]),
|
||||
];
|
||||
|
||||
$batchRequest = new BatchRequest(...$operations);
|
||||
$response = $this->batchProcessor->process($batchRequest);
|
||||
|
||||
$this->assertEquals(2, $response->totalOperations);
|
||||
$this->assertEquals(1, $response->successCount);
|
||||
$this->assertEquals(1, $response->failureCount);
|
||||
$this->assertTrue($response->hasPartialFailure());
|
||||
}
|
||||
|
||||
public function test_supports_fragments_in_batch_operations(): void
|
||||
{
|
||||
$operations = [
|
||||
BatchOperation::fromArray([
|
||||
'componentId' => 'counter:test1',
|
||||
'method' => 'increment',
|
||||
'params' => ['amount' => 5],
|
||||
'fragments' => ['counter-display'],
|
||||
]),
|
||||
];
|
||||
|
||||
$batchRequest = new BatchRequest(...$operations);
|
||||
$response = $this->batchProcessor->process($batchRequest);
|
||||
|
||||
$this->assertEquals(1, $response->successCount);
|
||||
$result = $response->getSuccessfulResults()[0];
|
||||
$this->assertTrue($result->hasFragments());
|
||||
$this->assertArrayHasKey('counter-display', $result->fragments);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\E2E;
|
||||
|
||||
use Tests\Feature\Framework\LiveComponents\TestHarness\LiveComponentTestCase;
|
||||
|
||||
/**
|
||||
* E2E Tests for Partial Rendering
|
||||
*
|
||||
* Tests fragment-based partial updates end-to-end.
|
||||
*/
|
||||
class PartialRenderingE2ETest extends LiveComponentTestCase
|
||||
{
|
||||
public function test_updates_single_fragment_via_action(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Call action with fragment request
|
||||
$this->call('increment', ['amount' => 5], ['counter-display']);
|
||||
|
||||
// Should return fragments instead of full HTML
|
||||
$html = $this->getHtml();
|
||||
$this->assertIsArray($html);
|
||||
$this->assertArrayHasKey('counter-display', $html);
|
||||
$this->assertStringContainsString('5', $html['counter-display']);
|
||||
|
||||
// State should be updated
|
||||
$this->seeStateKey('count', 5);
|
||||
}
|
||||
|
||||
public function test_updates_multiple_fragments_simultaneously(): void
|
||||
{
|
||||
// This would require a component with multiple fragments
|
||||
$this->markTestSkipped('Requires component with multiple fragments');
|
||||
}
|
||||
|
||||
public function test_falls_back_to_full_render_when_fragments_not_found(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Request non-existent fragment
|
||||
$this->call('increment', ['amount' => 5], ['non-existent-fragment']);
|
||||
|
||||
// Should fall back to full HTML
|
||||
$html = $this->getHtml();
|
||||
$this->assertIsString($html);
|
||||
$this->assertStringContainsString('5', $html);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
144
tests/Feature/Framework/LiveComponents/IslandRenderingTest.php
Normal file
144
tests/Feature/Framework/LiveComponents/IslandRenderingTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents;
|
||||
|
||||
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\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentState;
|
||||
use Tests\Feature\Framework\LiveComponents\TestHarness\LiveComponentTestHarness;
|
||||
|
||||
describe('Island Component Rendering', function () {
|
||||
beforeEach(function () {
|
||||
$this->harness = new LiveComponentTestHarness();
|
||||
});
|
||||
|
||||
it('renders Island component via endpoint', function () {
|
||||
$componentId = ComponentId::create('island-test', 'demo');
|
||||
|
||||
$response = $this->harness->get("/live-component/{$componentId->toString()}/island");
|
||||
|
||||
expect($response->status())->toBe(200);
|
||||
|
||||
$data = $response->json();
|
||||
expect($data['success'])->toBeTrue();
|
||||
expect($data)->toHaveKey('html');
|
||||
expect($data)->toHaveKey('state');
|
||||
expect($data)->toHaveKey('csrf_token');
|
||||
expect($data['component_id'])->toBe($componentId->toString());
|
||||
});
|
||||
|
||||
it('renders Island component without template wrapper', function () {
|
||||
$componentId = ComponentId::create('island-test', 'demo');
|
||||
|
||||
$response = $this->harness->get("/live-component/{$componentId->toString()}/island");
|
||||
|
||||
$data = $response->json();
|
||||
$html = $data['html'];
|
||||
|
||||
// Island HTML should not contain layout/meta wrappers
|
||||
// It should only contain the component HTML itself
|
||||
expect($html)->not->toContain('<html>');
|
||||
expect($html)->not->toContain('<head>');
|
||||
expect($html)->not->toContain('<body>');
|
||||
|
||||
// Should contain component-specific HTML
|
||||
expect($html)->toContain('data-component-id');
|
||||
});
|
||||
|
||||
it('generates lazy Island placeholder in XComponentProcessor', function () {
|
||||
// This test would require template rendering, which is complex
|
||||
// For now, we verify the endpoint works correctly
|
||||
$componentId = ComponentId::create('lazy-island-test', 'demo');
|
||||
|
||||
$response = $this->harness->get("/live-component/{$componentId->toString()}/island");
|
||||
|
||||
expect($response->status())->toBe(200);
|
||||
expect($response->json()['success'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles non-existent Island component gracefully', function () {
|
||||
$response = $this->harness->get('/live-component/nonexistent:test/island');
|
||||
|
||||
expect($response->status())->toBe(500);
|
||||
|
||||
$data = $response->json();
|
||||
expect($data['success'])->toBeFalse();
|
||||
expect($data)->toHaveKey('error');
|
||||
});
|
||||
|
||||
it('returns CSRF token for Island component', function () {
|
||||
$componentId = ComponentId::create('island-test', 'demo');
|
||||
|
||||
$response = $this->harness->get("/live-component/{$componentId->toString()}/island");
|
||||
|
||||
$data = $response->json();
|
||||
expect($data['csrf_token'])->not->toBeEmpty();
|
||||
expect($data['csrf_token'])->toBeString();
|
||||
});
|
||||
|
||||
it('returns component state for Island component', function () {
|
||||
$componentId = ComponentId::create('island-test', 'demo');
|
||||
|
||||
$response = $this->harness->get("/live-component/{$componentId->toString()}/island");
|
||||
|
||||
$data = $response->json();
|
||||
expect($data['state'])->toBeArray();
|
||||
expect($data['state'])->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
// Test Island component
|
||||
#[LiveComponent('island-test')]
|
||||
#[Island]
|
||||
final readonly class IslandTestComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public ComponentState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(
|
||||
templatePath: 'livecomponent-counter', // Reuse counter template for testing
|
||||
data: [
|
||||
'componentId' => $this->id->toString(),
|
||||
'stateJson' => json_encode($this->state->toArray()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(
|
||||
templatePath: 'livecomponent-counter',
|
||||
data: [
|
||||
'componentId' => $this->id->toString(),
|
||||
'stateJson' => json_encode($this->state->toArray()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents;
|
||||
|
||||
use App\Application\LiveComponents\Counter\CounterComponent;
|
||||
use App\Framework\LiveComponents\Attributes\Action;
|
||||
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
||||
use App\Framework\LiveComponents\Attributes\RequiresPermission;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
|
||||
use App\Framework\LiveComponents\LiveComponentHandler;
|
||||
use App\Application\LiveComponents\LiveComponentState;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use Tests\Feature\Framework\LiveComponents\TestHarness\LiveComponentTestCase;
|
||||
|
||||
/**
|
||||
* Comprehensive Security Tests for LiveComponents
|
||||
*
|
||||
* Tests all security features:
|
||||
* - CSRF Protection
|
||||
* - Rate Limiting
|
||||
* - Idempotency
|
||||
* - Action Allow-List
|
||||
* - Authorization
|
||||
*/
|
||||
class SecurityComprehensiveTest extends LiveComponentTestCase
|
||||
{
|
||||
public function test_csrf_protection_requires_valid_token(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Try to call action without CSRF token
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('CSRF token is required');
|
||||
|
||||
$this->call('increment', []);
|
||||
}
|
||||
|
||||
public function test_csrf_protection_rejects_invalid_token(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Try with invalid token
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('CSRF token validation failed');
|
||||
|
||||
$params = ActionParameters::fromArray([
|
||||
'csrf_token' => 'invalid-token-12345678901234567890123456789012'
|
||||
]);
|
||||
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
|
||||
$this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params
|
||||
);
|
||||
}
|
||||
|
||||
public function test_rate_limiting_enforces_limits(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Get valid CSRF token
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
||||
|
||||
// Execute action multiple times (assuming default limit is 10)
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$params = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString()
|
||||
]);
|
||||
|
||||
try {
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
|
||||
$this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params
|
||||
);
|
||||
|
||||
// Re-mount to get updated component
|
||||
$this->mount('counter:test', $this->getState());
|
||||
} catch (RateLimitExceededException $e) {
|
||||
// Expected after limit
|
||||
if ($i >= 9) {
|
||||
$this->assertInstanceOf(RateLimitExceededException::class, $e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11th request should be rate limited
|
||||
$this->expectException(RateLimitExceededException::class);
|
||||
|
||||
$params = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString()
|
||||
]);
|
||||
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
|
||||
$this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params
|
||||
);
|
||||
}
|
||||
|
||||
public function test_action_allow_list_only_allows_marked_actions(): void
|
||||
{
|
||||
$component = new TestComponentWithActions(
|
||||
ComponentId::create('test', 'demo'),
|
||||
LiveComponentState::empty()
|
||||
);
|
||||
|
||||
// Action with #[Action] should work
|
||||
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
||||
$params = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString()
|
||||
]);
|
||||
|
||||
$result = $this->handler->handle($component, 'allowedAction', $params);
|
||||
$this->assertNotNull($result);
|
||||
|
||||
// Action without #[Action] should fail
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('is not marked as an action');
|
||||
|
||||
$this->handler->handle($component, 'notAllowedAction', $params);
|
||||
}
|
||||
|
||||
public function test_reserved_methods_cannot_be_called_as_actions(): void
|
||||
{
|
||||
$component = new TestComponentWithActions(
|
||||
ComponentId::create('test', 'demo'),
|
||||
LiveComponentState::empty()
|
||||
);
|
||||
|
||||
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
||||
$params = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString()
|
||||
]);
|
||||
|
||||
// Try to call reserved method
|
||||
$this->expectException(\BadMethodCallException::class);
|
||||
$this->expectExceptionMessage('reserved method');
|
||||
|
||||
$this->handler->handle($component, 'mount', $params);
|
||||
}
|
||||
|
||||
public function test_authorization_requires_permission(): void
|
||||
{
|
||||
// This test would require a component with #[RequiresPermission]
|
||||
// and proper auth setup - skipping for now as it requires more setup
|
||||
$this->markTestSkipped('Requires authentication setup');
|
||||
}
|
||||
|
||||
public function test_idempotency_prevents_duplicate_execution(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
|
||||
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
||||
$idempotencyKey = 'test-key-' . uniqid();
|
||||
|
||||
// First execution
|
||||
$params1 = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString(),
|
||||
'idempotency_key' => $idempotencyKey
|
||||
]);
|
||||
|
||||
$result1 = $this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params1
|
||||
);
|
||||
|
||||
$this->assertEquals(1, $result1->state->data['count'] ?? 0);
|
||||
|
||||
// Second execution with same key should return cached result
|
||||
$params2 = ActionParameters::fromArray([
|
||||
'csrf_token' => $csrfToken->toString(),
|
||||
'idempotency_key' => $idempotencyKey
|
||||
]);
|
||||
|
||||
$result2 = $this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params2
|
||||
);
|
||||
|
||||
// Count should still be 1 (not 2) because action was cached
|
||||
$this->assertEquals(1, $result2->state->data['count'] ?? 0);
|
||||
}
|
||||
|
||||
public function test_security_order_csrf_before_rate_limit(): void
|
||||
{
|
||||
$this->mount('counter:test', ['count' => 0]);
|
||||
|
||||
// Invalid CSRF should fail before rate limit check
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('CSRF token is required');
|
||||
|
||||
$params = ActionParameters::fromArray([]);
|
||||
$component = $this->getComponent();
|
||||
if ($component === null) {
|
||||
$this->fail('Component not mounted');
|
||||
}
|
||||
|
||||
$this->handler->handle(
|
||||
$component,
|
||||
'increment',
|
||||
$params
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test component with actions
|
||||
#[LiveComponent('test-with-actions')]
|
||||
final readonly class TestComponentWithActions implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public LiveComponentState $state
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
|
||||
{
|
||||
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
|
||||
templatePath: 'test',
|
||||
data: []
|
||||
);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
public function allowedAction(): LiveComponentState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
// No #[Action] attribute - should not be callable
|
||||
public function notAllowedAction(): LiveComponentState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
// Reserved method - should not be callable
|
||||
public function mount(): LiveComponentState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\TestHarness;
|
||||
|
||||
/**
|
||||
* Snapshot Testing Utilities for LiveComponents
|
||||
*
|
||||
* Provides snapshot comparison for component render output with whitespace normalization.
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* $snapshot = ComponentSnapshotTest::createSnapshot('counter-initial', $html);
|
||||
* ComponentSnapshotTest::assertMatchesSnapshot($html, 'counter-initial');
|
||||
* ```
|
||||
*/
|
||||
final readonly class ComponentSnapshotTest
|
||||
{
|
||||
private const SNAPSHOT_DIR = __DIR__ . '/../../../../tests/snapshots/livecomponents';
|
||||
|
||||
/**
|
||||
* Normalize HTML for snapshot comparison
|
||||
*
|
||||
* Removes:
|
||||
* - Extra whitespace
|
||||
* - Line breaks
|
||||
* - Multiple spaces
|
||||
* - CSRF tokens (dynamic)
|
||||
* - Timestamps (dynamic)
|
||||
*/
|
||||
public static function normalizeHtml(string $html): string
|
||||
{
|
||||
// Remove CSRF tokens (they're dynamic)
|
||||
$html = preg_replace('/data-csrf-token="[^"]*"/', 'data-csrf-token="[CSRF_TOKEN]"', $html);
|
||||
|
||||
// Remove timestamps (they're dynamic)
|
||||
$html = preg_replace('/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', '[TIMESTAMP]', $html);
|
||||
|
||||
// Remove component IDs with random parts (keep structure)
|
||||
$html = preg_replace('/data-component-id="([^:]+):[^"]*"/', 'data-component-id="$1:[ID]"', $html);
|
||||
|
||||
// Normalize whitespace
|
||||
$html = preg_replace('/\s+/', ' ', $html);
|
||||
$html = trim($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update snapshot
|
||||
*
|
||||
* @param string $snapshotName Snapshot name (without extension)
|
||||
* @param string $html HTML to snapshot
|
||||
* @return string Path to snapshot file
|
||||
*/
|
||||
public static function createSnapshot(string $snapshotName, string $html): string
|
||||
{
|
||||
self::ensureSnapshotDir();
|
||||
|
||||
$normalized = self::normalizeHtml($html);
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
|
||||
file_put_contents($filePath, $normalized);
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert HTML matches snapshot
|
||||
*
|
||||
* @param string $html HTML to compare
|
||||
* @param string $snapshotName Snapshot name
|
||||
* @param bool $updateSnapshot If true, update snapshot instead of asserting
|
||||
* @throws \PHPUnit\Framework\AssertionFailedError If snapshot doesn't match
|
||||
*/
|
||||
public static function assertMatchesSnapshot(
|
||||
string $html,
|
||||
string $snapshotName,
|
||||
bool $updateSnapshot = false
|
||||
): void {
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
$normalized = self::normalizeHtml($html);
|
||||
|
||||
if ($updateSnapshot || !file_exists($filePath)) {
|
||||
self::createSnapshot($snapshotName, $html);
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = file_get_contents($filePath);
|
||||
|
||||
if ($normalized !== $expected) {
|
||||
$diff = self::generateDiff($expected, $normalized);
|
||||
|
||||
throw new \PHPUnit\Framework\AssertionFailedError(
|
||||
"Snapshot '{$snapshotName}' does not match.\n\n" .
|
||||
"Expected:\n{$expected}\n\n" .
|
||||
"Actual:\n{$normalized}\n\n" .
|
||||
"Diff:\n{$diff}\n\n" .
|
||||
"To update snapshot, set updateSnapshot=true or delete: {$filePath}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot file path
|
||||
*/
|
||||
private static function getSnapshotPath(string $snapshotName): string
|
||||
{
|
||||
return self::SNAPSHOT_DIR . '/' . $snapshotName . '.snapshot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure snapshot directory exists
|
||||
*/
|
||||
private static function ensureSnapshotDir(): void
|
||||
{
|
||||
if (!is_dir(self::SNAPSHOT_DIR)) {
|
||||
mkdir(self::SNAPSHOT_DIR, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diff between expected and actual
|
||||
*/
|
||||
private static function generateDiff(string $expected, string $actual): string
|
||||
{
|
||||
$expectedLines = explode("\n", $expected);
|
||||
$actualLines = explode("\n", $actual);
|
||||
|
||||
$diff = [];
|
||||
$maxLines = max(count($expectedLines), count($actualLines));
|
||||
|
||||
for ($i = 0; $i < $maxLines; $i++) {
|
||||
$expectedLine = $expectedLines[$i] ?? null;
|
||||
$actualLine = $actualLines[$i] ?? null;
|
||||
|
||||
if ($expectedLine === $actualLine) {
|
||||
$diff[] = " {$i}: {$expectedLine}";
|
||||
} else {
|
||||
if ($expectedLine !== null) {
|
||||
$diff[] = "- {$i}: {$expectedLine}";
|
||||
}
|
||||
if ($actualLine !== null) {
|
||||
$diff[] = "+ {$i}: {$actualLine}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all snapshots
|
||||
*
|
||||
* @return array<string> Snapshot names
|
||||
*/
|
||||
public static function listSnapshots(): array
|
||||
{
|
||||
self::ensureSnapshotDir();
|
||||
|
||||
$snapshots = [];
|
||||
$files = glob(self::SNAPSHOT_DIR . '/*.snapshot');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$snapshots[] = basename($file, '.snapshot');
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete snapshot
|
||||
*/
|
||||
public static function deleteSnapshot(string $snapshotName): bool
|
||||
{
|
||||
$filePath = self::getSnapshotPath($snapshotName);
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
return unlink($filePath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Framework\LiveComponents\TestHarness;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\LiveComponentHandler;
|
||||
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
|
||||
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
||||
use App\Framework\View\LiveComponentRenderer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Base Test Case for LiveComponent Tests
|
||||
*
|
||||
* Provides fluent API for testing LiveComponents:
|
||||
* - mount() - Mount component with initial state
|
||||
* - call() - Call action on component
|
||||
* - seeHtmlHas() - Assert HTML contains content
|
||||
* - seeStateEquals() - Assert state matches expected
|
||||
* - seeStateKey() - Assert specific state key
|
||||
* - seeEventDispatched() - Assert event was dispatched
|
||||
* - seeFragment() - Assert fragment contains content
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* class MyComponentTest extends LiveComponentTestCase
|
||||
* {
|
||||
* public function test_increments_counter(): void
|
||||
* {
|
||||
* $this->mount('counter:test', ['count' => 5])
|
||||
* ->call('increment')
|
||||
* ->seeStateKey('count', 6)
|
||||
* ->seeHtmlHas('Count: 6');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
abstract class LiveComponentTestCase extends TestCase
|
||||
{
|
||||
protected ComponentRegistry $registry;
|
||||
protected LiveComponentHandler $handler;
|
||||
protected LiveComponentRenderer $renderer;
|
||||
protected FragmentRenderer $fragmentRenderer;
|
||||
protected ?LiveComponentContract $currentComponent = null;
|
||||
protected ?ComponentUpdate $lastUpdate = null;
|
||||
protected string|array $lastHtml = '';
|
||||
protected array $lastState = [];
|
||||
protected array $lastEvents = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$container = $this->getContainer();
|
||||
$this->registry = $container->get(ComponentRegistry::class);
|
||||
$this->handler = $container->get(LiveComponentHandler::class);
|
||||
$this->renderer = $container->get(LiveComponentRenderer::class);
|
||||
$this->fragmentRenderer = $container->get(FragmentRenderer::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DI Container instance
|
||||
*/
|
||||
protected function getContainer(): Container
|
||||
{
|
||||
static $container = null;
|
||||
|
||||
if ($container === null) {
|
||||
// Bootstrap container for tests
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
$container = \createTestContainer();
|
||||
}
|
||||
|
||||
return $container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a component with initial state
|
||||
*
|
||||
* @param string $componentId Component ID (e.g., 'counter:test')
|
||||
* @param array $initialData Initial component data
|
||||
* @return $this
|
||||
*/
|
||||
protected function mount(string $componentId, array $initialData = []): self
|
||||
{
|
||||
$this->currentComponent = $this->registry->resolve(
|
||||
ComponentId::fromString($componentId),
|
||||
$initialData
|
||||
);
|
||||
|
||||
$this->lastHtml = $this->renderer->render($this->currentComponent);
|
||||
$this->lastState = $this->currentComponent->state->toArray();
|
||||
$this->lastEvents = [];
|
||||
$this->lastUpdate = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an action on the current component
|
||||
*
|
||||
* @param string $action Action name
|
||||
* @param array $params Action parameters
|
||||
* @param array $fragments Optional fragment names to extract
|
||||
* @return $this
|
||||
*/
|
||||
protected function call(string $action, array $params = [], array $fragments = []): self
|
||||
{
|
||||
if ($this->currentComponent === null) {
|
||||
throw new \RuntimeException('No component mounted. Call mount() first.');
|
||||
}
|
||||
|
||||
// Create ActionParameters with CSRF token
|
||||
$actionParams = ActionParameters::fromArray($params);
|
||||
|
||||
// Add CSRF token if not present
|
||||
if (!$actionParams->hasCsrfToken()) {
|
||||
$csrfToken = $this->registry->generateCsrfToken($this->currentComponent->id);
|
||||
$actionParams = ActionParameters::fromArray(array_merge($params, [
|
||||
'csrf_token' => $csrfToken->toString()
|
||||
]));
|
||||
}
|
||||
|
||||
// Execute action
|
||||
$this->lastUpdate = $this->handler->handle(
|
||||
$this->currentComponent,
|
||||
$action,
|
||||
$actionParams
|
||||
);
|
||||
|
||||
// Resolve component with updated state
|
||||
$this->currentComponent = $this->registry->resolve(
|
||||
$this->currentComponent->id,
|
||||
$this->lastUpdate->state->data
|
||||
);
|
||||
|
||||
// Render updated component
|
||||
if (!empty($fragments)) {
|
||||
$fragmentCollection = $this->fragmentRenderer->renderFragments(
|
||||
$this->currentComponent,
|
||||
$fragments
|
||||
);
|
||||
|
||||
$this->lastHtml = [];
|
||||
foreach ($fragmentCollection as $fragment) {
|
||||
$this->lastHtml[$fragment->name] = $fragment->content;
|
||||
}
|
||||
} else {
|
||||
$this->lastHtml = $this->renderer->render($this->currentComponent);
|
||||
}
|
||||
|
||||
$this->lastState = $this->currentComponent->state->toArray();
|
||||
$this->lastEvents = $this->lastUpdate->events;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert HTML contains the given content
|
||||
*
|
||||
* @param string $needle Content to search for
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeHtmlHas(string $needle): self
|
||||
{
|
||||
$html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml;
|
||||
$this->assertStringContainsString($needle, $html, "Expected HTML to contain '{$needle}'");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert HTML does not contain the given content
|
||||
*
|
||||
* @param string $needle Content to search for
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeHtmlNotHas(string $needle): self
|
||||
{
|
||||
$html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml;
|
||||
$this->assertStringNotContainsString($needle, $html, "Expected HTML not to contain '{$needle}'");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert state equals expected state
|
||||
*
|
||||
* @param array $expectedState Expected state array
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeStateEquals(array $expectedState): self
|
||||
{
|
||||
$this->assertEquals($expectedState, $this->lastState, 'State does not match expected');
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert specific state key has expected value
|
||||
*
|
||||
* @param string $key State key
|
||||
* @param mixed $value Expected value
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeStateKey(string $key, mixed $value): self
|
||||
{
|
||||
$this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found");
|
||||
$this->assertEquals($value, $this->lastState[$key], "State key '{$key}' does not match expected value");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert state key exists
|
||||
*
|
||||
* @param string $key State key
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeStateHasKey(string $key): self
|
||||
{
|
||||
$this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert event was dispatched
|
||||
*
|
||||
* @param string $eventName Event name
|
||||
* @param array|null $expectedPayload Optional expected payload
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeEventDispatched(string $eventName, ?array $expectedPayload = null): self
|
||||
{
|
||||
$found = false;
|
||||
foreach ($this->lastEvents as $event) {
|
||||
if ($event->name === $eventName) {
|
||||
$found = true;
|
||||
if ($expectedPayload !== null) {
|
||||
$actualPayload = $event->payload->toArray();
|
||||
$this->assertEquals($expectedPayload, $actualPayload, "Event '{$eventName}' payload does not match");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertTrue($found, "Event '{$eventName}' was not dispatched");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert event was not dispatched
|
||||
*
|
||||
* @param string $eventName Event name
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeEventNotDispatched(string $eventName): self
|
||||
{
|
||||
foreach ($this->lastEvents as $event) {
|
||||
if ($event->name === $eventName) {
|
||||
$this->fail("Event '{$eventName}' was dispatched but should not have been");
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert fragment contains content
|
||||
*
|
||||
* @param string $fragmentName Fragment name
|
||||
* @param string $content Expected content
|
||||
* @return $this
|
||||
*/
|
||||
protected function seeFragment(string $fragmentName, string $content): self
|
||||
{
|
||||
if (!is_array($this->lastHtml)) {
|
||||
$this->fail('Fragments not requested. Use call() with fragments parameter.');
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey($fragmentName, $this->lastHtml, "Fragment '{$fragmentName}' not found");
|
||||
$this->assertStringContainsString($content, $this->lastHtml[$fragmentName], "Fragment '{$fragmentName}' does not contain expected content");
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert component instance
|
||||
*
|
||||
* @param string $expectedClass Expected component class
|
||||
* @return $this
|
||||
*/
|
||||
protected function assertComponent(string $expectedClass): self
|
||||
{
|
||||
$this->assertNotNull($this->currentComponent, 'No component mounted');
|
||||
$this->assertInstanceOf($expectedClass, $this->currentComponent);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current component instance
|
||||
*/
|
||||
protected function getComponent(): ?LiveComponentContract
|
||||
{
|
||||
return $this->currentComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current HTML
|
||||
*/
|
||||
protected function getHtml(): string|array
|
||||
{
|
||||
return $this->lastHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
protected function getState(): array
|
||||
{
|
||||
return $this->lastState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last dispatched events
|
||||
*/
|
||||
protected function getEvents(): array
|
||||
{
|
||||
return $this->lastEvents;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Http\Session\FileSessionStorage;
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
use App\Framework\Http\Session\Session;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
use App\Framework\Http\Session\SessionIdGenerator;
|
||||
use App\Framework\Http\Session\SessionManager;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
use App\Framework\Security\CsrfTokenGenerator;
|
||||
use App\Framework\View\Response\FormDataResponseProcessor;
|
||||
use App\Framework\Http\Cookies\SessionCookieConfig;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tempDir = sys_get_temp_dir() . '/php_sessions_test_' . uniqid();
|
||||
mkdir($this->tempDir, 0700, true);
|
||||
|
||||
$this->clock = new SystemClock();
|
||||
$this->storage = new FileSessionStorage($this->tempDir, $this->clock);
|
||||
$this->sessionIdGenerator = new SessionIdGenerator(new SecureRandomGenerator());
|
||||
$this->csrfTokenGenerator = new CsrfTokenGenerator(new SecureRandomGenerator());
|
||||
$this->formIdGenerator = new FormIdGenerator();
|
||||
|
||||
$this->cookieConfig = new SessionCookieConfig(
|
||||
name: 'test_session',
|
||||
lifetime: 3600,
|
||||
path: '/',
|
||||
domain: null,
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
sameSite: \App\Framework\Http\Cookies\SameSite::LAX
|
||||
);
|
||||
|
||||
$this->sessionManager = new SessionManager(
|
||||
generator: $this->sessionIdGenerator,
|
||||
responseManipulator: Mockery::mock(\App\Framework\Http\ResponseManipulator::class),
|
||||
clock: $this->clock,
|
||||
csrfTokenGenerator: $this->csrfTokenGenerator,
|
||||
storage: $this->storage,
|
||||
cookieConfig: $this->cookieConfig
|
||||
);
|
||||
|
||||
$this->sessionId = $this->sessionIdGenerator->generate();
|
||||
$this->session = Session::fromArray($this->sessionId, $this->clock, $this->csrfTokenGenerator, []);
|
||||
|
||||
$this->processor = new FormDataResponseProcessor(
|
||||
$this->formIdGenerator,
|
||||
$this->sessionManager
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (isset($this->tempDir) && is_dir($this->tempDir)) {
|
||||
array_map('unlink', glob($this->tempDir . '/*'));
|
||||
rmdir($this->tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('processes form HTML and replaces token placeholder', function () {
|
||||
$formId = $this->formIdGenerator->generateFormId('/test', 'post');
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<form method="post" action="/test">
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
<input type="text" name="email" value="">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$processed = $this->processor->process($html, $this->session);
|
||||
|
||||
// Token should be replaced
|
||||
expect($processed)->not->toContain("___TOKEN_{$formId}___");
|
||||
|
||||
// Should contain a valid token
|
||||
preg_match('/name="_token"[^>]*value="([^"]+)"/', $processed, $matches);
|
||||
expect($matches)->toHaveCount(2);
|
||||
|
||||
$token = $matches[1];
|
||||
expect(strlen($token))->toBe(64);
|
||||
expect(ctype_xdigit($token))->toBeTrue();
|
||||
|
||||
// Token should be valid in session
|
||||
$tokenObj = \App\Framework\Security\CsrfToken::fromString($token);
|
||||
$result = $this->session->csrf->validateTokenWithDebug($formId, $tokenObj);
|
||||
expect($result['valid'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('processes multiple forms with different form IDs', function () {
|
||||
$formId1 = $this->formIdGenerator->generateFormId('/form1', 'post');
|
||||
$formId2 = $this->formIdGenerator->generateFormId('/form2', 'post');
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<form method="post" action="/form1">
|
||||
<input type="hidden" name="_form_id" value="{$formId1}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId1}___">
|
||||
</form>
|
||||
<form method="post" action="/form2">
|
||||
<input type="hidden" name="_form_id" value="{$formId2}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId2}___">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$processed = $this->processor->process($html, $this->session);
|
||||
|
||||
// Both tokens should be replaced
|
||||
expect($processed)->not->toContain("___TOKEN_{$formId1}___");
|
||||
expect($processed)->not->toContain("___TOKEN_{$formId2}___");
|
||||
|
||||
// Extract tokens
|
||||
preg_match_all('/name="_token"[^>]*value="([^"]+)"/', $processed, $matches);
|
||||
expect($matches[1])->toHaveCount(2);
|
||||
|
||||
$token1 = $matches[1][0];
|
||||
$token2 = $matches[1][1];
|
||||
|
||||
// Tokens should be different
|
||||
expect($token1)->not->toBe($token2);
|
||||
|
||||
// Both should be valid
|
||||
expect(strlen($token1))->toBe(64);
|
||||
expect(strlen($token2))->toBe(64);
|
||||
});
|
||||
|
||||
it('handles malformed HTML gracefully', function () {
|
||||
$formId = $this->formIdGenerator->generateFormId('/test', 'post');
|
||||
|
||||
// HTML with unclosed tags
|
||||
$html = <<<HTML
|
||||
<form method="post" action="/test">
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
<div>
|
||||
<unclosed-tag>
|
||||
</form>
|
||||
HTML;
|
||||
|
||||
// Should not throw exception
|
||||
$processed = $this->processor->process($html, $this->session);
|
||||
|
||||
// Should still replace token (via regex fallback)
|
||||
expect($processed)->not->toContain("___TOKEN_{$formId}___");
|
||||
});
|
||||
|
||||
it('preserves HTML structure after processing', function () {
|
||||
$formId = $this->formIdGenerator->generateFormId('/test', 'post');
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Test Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Form</h1>
|
||||
<form method="post" action="/test">
|
||||
<input type="hidden" name="_form_id" value="{$formId}">
|
||||
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
|
||||
<label>Email:</label>
|
||||
<input type="email" name="email">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$processed = $this->processor->process($html, $this->session);
|
||||
|
||||
// Should preserve structure
|
||||
expect($processed)->toContain('<!DOCTYPE html>');
|
||||
expect($processed)->toContain('<html>');
|
||||
expect($processed)->toContain('<head>');
|
||||
expect($processed)->toContain('<title>Test Page</title>');
|
||||
expect($processed)->toContain('<h1>Test Form</h1>');
|
||||
expect($processed)->toContain('<label>Email:</label>');
|
||||
expect($processed)->toContain('<button type="submit">Submit</button>');
|
||||
|
||||
// Token should be replaced
|
||||
expect($processed)->not->toContain("___TOKEN_{$formId}___");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user