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,68 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Attributes;
use App\Framework\LiveComponents\Attributes\Island;
use PHPUnit\Framework\TestCase;
final class IslandTest extends TestCase
{
public function test_can_create_island_attribute_with_defaults(): void
{
$island = new Island();
$this->assertTrue($island->isolated);
$this->assertFalse($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_island_attribute_with_custom_values(): void
{
$island = new Island(
isolated: true,
lazy: true,
placeholder: 'Loading widget...'
);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertSame('Loading widget...', $island->placeholder);
}
public function test_can_create_non_isolated_island(): void
{
$island = new Island(isolated: false);
$this->assertFalse($island->isolated);
$this->assertFalse($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_lazy_island_without_placeholder(): void
{
$island = new Island(lazy: true);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_lazy_island_with_placeholder(): void
{
$island = new Island(
lazy: true,
placeholder: 'Please wait...'
);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertSame('Please wait...', $island->placeholder);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\DI\Container;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewarePipeline;
use App\Framework\LiveComponents\Middleware\MiddlewareRegistration;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware2;
use Tests\Unit\Framework\LiveComponents\Middleware\TestPassThroughMiddleware;
use Tests\Unit\Framework\LiveComponents\Middleware\TestCaptureMiddleware;
// Ensure test middleware classes are loaded
require_once __DIR__ . '/TestMiddleware.php';
describe('ComponentMiddlewarePipeline', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
});
it('executes middleware in priority order', function () {
$executionOrder = [];
// Register middleware in container
$this->container->instance(TestMiddleware1::class, new TestMiddleware1($executionOrder));
$this->container->instance(TestMiddleware2::class, new TestMiddleware2($executionOrder));
// Create pipeline with middleware (higher priority first)
$middlewares = [
new MiddlewareRegistration(TestMiddleware1::class, priority: 200),
new MiddlewareRegistration(TestMiddleware2::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
// Create test component
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
// Execute pipeline
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => new ComponentUpdate(
component: $c,
state: LiveComponentState::fromArray([]),
events: []
)
);
// Middleware should execute in priority order (higher first)
expect($executionOrder)->toBe(['middleware1', 'middleware2']);
});
it('passes component, action, and params through middleware chain', function () {
$receivedComponent = null;
$receivedAction = null;
$receivedParams = null;
$middleware = new TestCaptureMiddleware($receivedComponent, $receivedAction, $receivedParams);
$this->container->instance(TestCaptureMiddleware::class, $middleware);
$middlewares = [
new MiddlewareRegistration(TestCaptureMiddleware::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$params = ActionParameters::fromArray(['test' => 'value']);
$pipeline->process(
$component,
'testAction',
$params,
fn($c, $a, $p) => new ComponentUpdate(
component: $c,
state: LiveComponentState::fromArray([]),
events: []
)
);
expect($receivedComponent)->toBe($component);
expect($receivedAction)->toBe('testAction');
expect($receivedParams)->toBe($params);
});
it('returns action handler result', function () {
$expectedResult = new ComponentUpdate(
component: new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
},
state: LiveComponentState::fromArray(['result' => 'success']),
events: []
);
$middleware = new TestPassThroughMiddleware();
$this->container->instance(TestPassThroughMiddleware::class, $middleware);
$middlewares = [
new MiddlewareRegistration(TestPassThroughMiddleware::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => $expectedResult
);
expect($result)->toBe($expectedResult);
});
it('handles empty middleware array', function () {
$pipeline = new ComponentMiddlewarePipeline([], $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$expectedResult = new ComponentUpdate(
component: $component,
state: LiveComponentState::fromArray([]),
events: []
);
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => $expectedResult
);
expect($result)->toBe($expectedResult);
});
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\Results\TemplateRegistry;
use App\Framework\Discovery\ValueObjects\AttributeTarget;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\LiveComponents\Attributes\Middleware as MiddlewareAttribute;
use App\Framework\LiveComponents\Middleware\MiddlewareCollector;
use App\Framework\LiveComponents\Middleware\LoggingMiddleware;
describe('MiddlewareCollector', function () {
beforeEach(function () {
$this->attributeRegistry = new AttributeRegistry();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: $this->attributeRegistry,
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->collector = new MiddlewareCollector($this->discoveryRegistry);
});
it('collects component-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
// Add component-level middleware attribute
$discovered = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 100]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toHaveCount(1);
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
expect($middlewares[0]->priority)->toBe(100);
});
it('collects action-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
$actionMethod = MethodName::create('testAction');
// Add action-level middleware attribute
$discovered = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_METHOD,
methodName: $actionMethod,
arguments: [LoggingMiddleware::class, 200]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
$middlewares = $this->collector->collectForAction(
$componentClass,
$actionMethod
);
expect($middlewares)->toHaveCount(1);
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
expect($middlewares[0]->priority)->toBe(200);
});
it('combines component and action-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
$actionMethod = MethodName::create('testAction');
// Add component-level middleware
$componentMiddleware = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 100]
);
// Add action-level middleware
$actionMiddleware = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_METHOD,
methodName: $actionMethod,
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 200]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $componentMiddleware);
$this->attributeRegistry->add(MiddlewareAttribute::class, $actionMiddleware);
$middlewares = $this->collector->collectForAction(
$componentClass,
$actionMethod
);
expect($middlewares)->toHaveCount(2);
// Should be sorted by priority (higher first)
expect($middlewares[0]->priority)->toBe(200);
expect($middlewares[1]->priority)->toBe(100);
});
it('sorts middleware by priority descending', function () {
$componentClass = ClassName::create('Test\\Component');
// Add multiple middleware with different priorities
$middleware1 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 50]
);
$middleware2 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 150]
);
$middleware3 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [\App\Framework\LiveComponents\Middleware\RateLimitMiddleware::class, 100]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware1);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware2);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware3);
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toHaveCount(3);
// Should be sorted by priority descending
expect($middlewares[0]->priority)->toBe(150);
expect($middlewares[1]->priority)->toBe(100);
expect($middlewares[2]->priority)->toBe(50);
});
it('returns empty array when no middleware found', function () {
$componentClass = ClassName::create('Test\\Component');
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toBeEmpty();
});
});

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
it('can create middleware', function () {
$order = [];
$middleware = new TestMiddleware1($order);
expect($middleware)->toBeInstanceOf(ComponentMiddlewareInterface::class);
});

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
/**
* Test Middleware for unit tests
*/
final class TestMiddleware1 implements ComponentMiddlewareInterface
{
private array $executionOrder;
public function __construct(
array &$executionOrder
) {
$this->executionOrder = &$executionOrder;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->executionOrder[] = 'middleware1';
return $next($component, $action, $params);
}
}
final class TestMiddleware2 implements ComponentMiddlewareInterface
{
private array $executionOrder;
public function __construct(
array &$executionOrder
) {
$this->executionOrder = &$executionOrder;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->executionOrder[] = 'middleware2';
return $next($component, $action, $params);
}
}
final class TestPassThroughMiddleware implements ComponentMiddlewareInterface
{
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
return $next($component, $action, $params);
}
}
final class TestCaptureMiddleware implements ComponentMiddlewareInterface
{
private ?LiveComponentContract $capturedComponent;
private ?string $capturedAction;
private ?ActionParameters $capturedParams;
public function __construct(
?LiveComponentContract &$capturedComponent,
?string &$capturedAction,
?ActionParameters &$capturedParams
) {
$this->capturedComponent = &$capturedComponent;
$this->capturedAction = &$capturedAction;
$this->capturedParams = &$capturedParams;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->capturedComponent = $component;
$this->capturedAction = $action;
$this->capturedParams = $params;
return $next($component, $action, $params);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Performance;
use App\Application\LiveComponents\Counter\CounterComponent;
use App\Framework\LiveComponents\Attributes\Island;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Performance\ComponentMetadataCompiler;
use App\Framework\LiveComponents\Performance\CompiledComponentMetadata;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
describe('Island Metadata Detection', function () {
it('detects Island attribute in component', function () {
$compiler = new ComponentMetadataCompiler();
// Create a test component with Island attribute
$componentClass = IslandTestComponent::class;
$metadata = $compiler->compile($componentClass);
expect($metadata->isIsland())->toBeTrue();
expect($metadata->getIsland())->not->toBeNull();
$island = $metadata->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeFalse();
expect($island['placeholder'])->toBeNull();
});
it('detects Island attribute with lazy loading', function () {
$compiler = new ComponentMetadataCompiler();
$componentClass = LazyIslandTestComponent::class;
$metadata = $compiler->compile($componentClass);
expect($metadata->isIsland())->toBeTrue();
$island = $metadata->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeTrue();
expect($island['placeholder'])->toBe('Loading component...');
});
it('returns null for non-island components', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(CounterComponent::class);
expect($metadata->isIsland())->toBeFalse();
expect($metadata->getIsland())->toBeNull();
});
it('serializes Island metadata in toArray', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(IslandTestComponent::class);
$array = $metadata->toArray();
expect($array)->toHaveKey('island');
expect($array['island'])->not->toBeNull();
expect($array['island']['isolated'])->toBeTrue();
expect($array['island']['lazy'])->toBeFalse();
});
it('deserializes Island metadata from array', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(IslandTestComponent::class);
$array = $metadata->toArray();
$restored = CompiledComponentMetadata::fromArray($array);
expect($restored->isIsland())->toBeTrue();
expect($restored->getIsland())->not->toBeNull();
$island = $restored->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeFalse();
});
});
// Test component classes
#[LiveComponent('island-test')]
#[Island]
final readonly class IslandTestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
templatePath: 'test',
data: []
);
}
}
#[LiveComponent('lazy-island-test')]
#[Island(isolated: true, lazy: true, placeholder: 'Loading component...')]
final readonly class LazyIslandTestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
templatePath: 'test',
data: []
);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Security\ActionValidator;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use Tests\Unit\Framework\LiveComponents\Security\ArrayReturnTypeComponent;
use Tests\Unit\Framework\LiveComponents\Security\PrimitiveReturnTypeComponent;
use Tests\Unit\Framework\LiveComponents\Security\PrivateActionComponent;
use Tests\Unit\Framework\LiveComponents\Security\StaticActionComponent;
use Tests\Unit\Framework\LiveComponents\Security\ValidActionComponent;
use PHPUnit\Framework\TestCase;
final class ActionValidatorTest extends TestCase
{
private ActionValidator $validator;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
$this->container = new DefaultContainer();
$this->validator = new ActionValidator();
}
public function test_validates_valid_action(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'validAction');
$actionAttribute = new Action();
// Should not throw
$this->validator->validate($context, $actionAttribute);
$this->assertTrue(true);
}
public function test_rejects_reserved_method(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'onMount');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('reserved method');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_non_existent_method(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'nonExistentMethod');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('not found');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_private_method(): void
{
$component = new PrivateActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'privateAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must be public');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_static_method(): void
{
$component = new StaticActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'staticAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('cannot be static');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_primitive_return_type(): void
{
$component = new PrimitiveReturnTypeComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'intAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must return a State object');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_array_return_type(): void
{
$component = new ArrayReturnTypeComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'arrayAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must return a State object');
$this->validator->validate($context, $actionAttribute);
}
private function createValidComponent(): LiveComponentContract
{
return new ValidActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, string $methodName): AttributeExecutionContext
{
$componentClass = ClassName::create($component::class);
$method = MethodName::create($methodName);
$componentId = $component->id;
$actionParameters = ActionParameters::fromArray([]);
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: $componentClass,
actionMethod: $method,
componentId: $componentId,
actionParameters: $actionParameters,
component: $component
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Security\Guards\LiveComponentCsrfGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextData;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Http\Session\CsrfProtection;
use App\Framework\Security\CsrfToken;
use App\Framework\Security\Guards\CsrfGuard;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class LiveComponentCsrfGuardTest extends TestCase
{
private LiveComponentCsrfGuard $guard;
private CsrfGuard $csrfGuard;
private SessionInterface&MockObject $session;
private CsrfProtection&MockObject $csrfProtection;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
// Since CsrfProtection is final, we skip mocking it
// These tests focus on the LiveComponent-specific logic (context extraction, form ID generation)
// The actual CSRF validation is tested in integration tests
$this->session = $this->createMock(SessionInterface::class);
$this->csrfGuard = new CsrfGuard($this->session);
$this->container = new DefaultContainer();
$this->container->instance(CsrfGuard::class, $this->csrfGuard);
$this->container->instance(SessionInterface::class, $this->session);
$this->guard = new LiveComponentCsrfGuard($this->csrfGuard);
}
public function test_validates_valid_csrf_token(): void
{
// This test is skipped because CsrfProtection is final and cannot be mocked
// The CSRF validation logic is tested in integration tests
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
}
public function test_rejects_missing_csrf_token(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('CSRF token is required');
$this->guard->validate($context);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->validate($context);
}
public function test_uses_component_id_as_form_id(): void
{
// This test is skipped because CsrfProtection is final and cannot be mocked
// The form ID generation logic is tested in integration tests
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\RequiresPermission;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
use App\Framework\LiveComponents\Security\Guards\LiveComponentPermissionGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class LiveComponentPermissionGuardTest extends TestCase
{
private LiveComponentPermissionGuard $guard;
private ActionAuthorizationChecker&MockObject $authorizationChecker;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
$this->authorizationChecker = $this->createMock(ActionAuthorizationChecker::class);
$this->container = new DefaultContainer();
$this->container->instance(ActionAuthorizationChecker::class, $this->authorizationChecker);
$this->guard = new LiveComponentPermissionGuard($this->authorizationChecker);
}
public function test_allows_authorized_action(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->with($component, 'testAction', $permissionAttribute)
->willReturn(true);
// Should not throw
$this->guard->check($context, $permissionAttribute);
$this->assertTrue(true);
}
public function test_rejects_unauthorized_action(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->willReturn(false);
$this->authorizationChecker
->expects($this->once())
->method('isAuthenticated')
->willReturn(true);
$this->authorizationChecker
->expects($this->once())
->method('getUserPermissions')
->willReturn(['view_post']);
$this->expectException(UnauthorizedActionException::class);
$this->guard->check($context, $permissionAttribute);
}
public function test_rejects_unauthenticated_user(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->willReturn(false);
$this->authorizationChecker
->expects($this->once())
->method('isAuthenticated')
->willReturn(false);
$this->expectException(UnauthorizedActionException::class);
$this->expectExceptionMessage('requires authentication');
$this->guard->check($context, $permissionAttribute);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$permissionAttribute = new RequiresPermission('edit_post');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->check($context, $permissionAttribute);
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\ClientIdentifier;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
use App\Framework\LiveComponents\Security\Guards\LiveComponentRateLimitGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
use App\Framework\LiveComponents\Services\RateLimitResult;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\GeneralCache;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;
use App\Framework\RateLimit\RateLimiter;
use App\Framework\RateLimit\Storage\CacheStorage;
use Tests\Unit\Framework\LiveComponents\Security\TestComponent;
use PHPUnit\Framework\TestCase;
final class LiveComponentRateLimitGuardTest extends TestCase
{
private LiveComponentRateLimitGuard $guard;
private LiveComponentRateLimiter $rateLimiter;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
// Use real instance since LiveComponentRateLimiter is final
$this->container = new DefaultContainer();
$cacheDriver = new InMemoryCache();
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
$cache = new GeneralCache($cacheDriver, $serializer);
$storage = new CacheStorage($cache);
$baseRateLimiter = new RateLimiter($storage);
$this->container->instance(\App\Framework\RateLimit\RateLimiter::class, $baseRateLimiter);
$this->rateLimiter = new LiveComponentRateLimiter($baseRateLimiter);
$this->guard = new LiveComponentRateLimitGuard($this->rateLimiter);
}
public function test_skips_check_when_no_client_identifier(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$actionAttribute = new Action(rateLimit: 10);
// Should not throw and not call rate limiter (no client identifier)
$this->guard->check($context, $actionAttribute);
$this->assertTrue(true);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$actionAttribute = new Action();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->check($context, $actionAttribute);
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
final class TestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* Test Component with valid action
*/
final class ValidActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function validAction(): CounterState
{
return $this->state;
}
}
/**
* Test Component with private action (should fail validation)
*/
final class PrivateActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
private function privateAction(): CounterState
{
return $this->state;
}
}
/**
* Test Component with static action (should fail validation)
*/
final class StaticActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public static function staticAction(): CounterState
{
return CounterState::empty();
}
}
/**
* Test Component with primitive return type (should fail validation)
*/
final class PrimitiveReturnTypeComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function intAction(): int
{
return 42;
}
}
/**
* Test Component with array return type (should fail validation)
*/
final class ArrayReturnTypeComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function arrayAction(): array
{
return [];
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\UI;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\UI\UIHelper;
use App\Framework\LiveComponents\ValueObjects\ComponentEvent;
use PHPUnit\Framework\TestCase;
/**
* Test class for UIHelper
*/
final class UIHelperTest extends TestCase
{
public function testShowToastDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->toast('Test message', 'success', null);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertInstanceOf(ComponentEvent::class, $event);
$this->assertEquals('toast:show', $event->name);
$this->assertTrue($event->isBroadcast());
$payload = $event->payload;
$this->assertEquals('Test message', $payload->getString('message'));
$this->assertEquals('success', $payload->getString('type'));
}
public function testShowToastWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->toast('Test message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('info', $payload->getString('type'));
$this->assertEquals(5000, $payload->getInt('duration'));
$this->assertEquals('top-right', $payload->getString('position'));
$this->assertEquals('global', $payload->getString('componentId'));
}
public function testShowToastWithNullEvents(): void
{
$ui = new UIHelper(null);
// Should not throw error
$ui->toast('Test message');
$this->assertTrue(true); // Test passes if no exception thrown
}
public function testSuccessToast(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->successToast('Success message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('success', $payload->getString('type'));
$this->assertEquals('Success message', $payload->getString('message'));
}
public function testErrorToast(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->errorToast('Error message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('error', $payload->getString('type'));
$this->assertEquals(0, $payload->getInt('duration')); // Persistent by default
}
public function testHideToastDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->hideToast('test-component');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('toast:hide', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
}
public function testShowModalDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->modal(
'test-component',
'Test Title',
'<p>Test content</p>',
\App\Framework\LiveComponents\Events\UI\Options\ModalOptions::create()
->withSize(\App\Framework\LiveComponents\Events\UI\Enums\ModalSize::Large)
->withButtons([['text' => 'OK', 'class' => 'btn-primary']])
->closeOnBackdrop(false)
->closeOnEscape(false)
);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:show', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Test Title', $payload->getString('title'));
$this->assertEquals('<p>Test content</p>', $payload->getString('content'));
$this->assertEquals('large', $payload->getString('size'));
$this->assertFalse($payload->getBool('closeOnBackdrop'));
$this->assertFalse($payload->getBool('closeOnEscape'));
}
public function testShowModalWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->modal('test-component', 'Title', 'Content');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('medium', $payload->getString('size'));
$this->assertTrue($payload->getBool('closeOnBackdrop'));
$this->assertTrue($payload->getBool('closeOnEscape'));
}
public function testCloseModalDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->closeModal('test-component');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:close', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
}
public function testShowConfirmDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirm(
'test-component',
'Confirm Title',
'Confirm message',
\App\Framework\LiveComponents\Events\UI\Options\ConfirmOptions::create()
->withButtons('Yes', 'No')
->withClasses('btn-danger', 'btn-secondary')
);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:confirm', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Confirm Title', $payload->getString('title'));
$this->assertEquals('Confirm message', $payload->getString('message'));
$this->assertEquals('Yes', $payload->getString('confirmText'));
$this->assertEquals('No', $payload->getString('cancelText'));
}
public function testShowConfirmWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirm('test-component', 'Title', 'Message');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('Confirm', $payload->getString('confirmText'));
$this->assertEquals('Cancel', $payload->getString('cancelText'));
$this->assertEquals('btn-primary', $payload->getString('confirmClass'));
$this->assertEquals('btn-secondary', $payload->getString('cancelClass'));
}
public function testConfirmDelete(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirmDelete('test-component', 'Item Name', 'deleteAction', ['id' => 123]);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('Delete Item Name?', $payload->getString('title'));
$this->assertEquals("Are you sure you want to delete 'Item Name'? This action cannot be undone.", $payload->getString('message'));
$this->assertEquals('Delete', $payload->getString('confirmText'));
$this->assertEquals('btn-danger', $payload->getString('confirmClass'));
$this->assertEquals('deleteAction', $payload->getString('confirmAction'));
$this->assertEquals(['id' => 123], $payload->getArray('confirmParams'));
}
public function testShowAlertDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->alert('test-component', 'Alert Title', 'Alert message', 'error', 'OK');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:alert', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Alert Title', $payload->getString('title'));
$this->assertEquals('Alert message', $payload->getString('message'));
$this->assertEquals('error', $payload->getString('type'));
$this->assertEquals('OK', $payload->getString('buttonText'));
}
public function testShowAlertWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->alert('test-component', 'Title', 'Message');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('info', $payload->getString('type'));
$this->assertEquals('OK', $payload->getString('buttonText'));
}
public function testFluentInterface(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$result = $ui->successToast('Saved!')
->infoToast('Processing...')
->modal('modal-1', 'Title', 'Content');
$this->assertSame($ui, $result);
$this->assertCount(3, $events->getEvents());
}
public function testComponentIdAsValueObject(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$componentId = \App\Framework\LiveComponents\ValueObjects\ComponentId::fromString('test:component');
$ui->modal($componentId, 'Title', 'Content');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('test:component', $payload->getString('componentId'));
}
}