fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View 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);
});

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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()),
]
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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}___");
});