Files
michaelschiemer/tests/Feature/Framework/LiveComponents/ActionAuthorizationTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

428 lines
15 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionId;
use App\Framework\LiveComponents\Attributes\RequiresPermission;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\Security\SessionBasedAuthorizationChecker;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Security\CsrfTokenGenerator;
beforeEach(function () {
// Create real Session instance
$sessionId = SessionId::fromString(bin2hex(random_bytes(16)));
$clock = new SystemClock();
$randomGenerator = new SecureRandomGenerator();
$csrfGenerator = new CsrfTokenGenerator($randomGenerator);
$this->session = Session::fromArray($sessionId, $clock, $csrfGenerator, []);
$this->eventDispatcher = new ComponentEventDispatcher();
$this->authChecker = new SessionBasedAuthorizationChecker($this->session);
$this->handler = new LiveComponentHandler(
$this->eventDispatcher,
$this->session,
$this->authChecker
);
});
describe('RequiresPermission Attribute', function () {
it('validates permission attribute requires at least one permission', function () {
expect(fn () => new RequiresPermission())
->toThrow(\InvalidArgumentException::class, 'at least one permission');
});
it('checks if user has required permission', function () {
$attribute = new RequiresPermission('posts.edit');
expect($attribute->isAuthorized(['posts.edit', 'posts.view']))->toBeTrue();
expect($attribute->isAuthorized(['posts.view']))->toBeFalse();
expect($attribute->isAuthorized([]))->toBeFalse();
});
it('checks multiple permissions with OR logic', function () {
$attribute = new RequiresPermission('posts.edit', 'posts.admin');
expect($attribute->isAuthorized(['posts.edit']))->toBeTrue();
expect($attribute->isAuthorized(['posts.admin']))->toBeTrue();
expect($attribute->isAuthorized(['posts.view']))->toBeFalse();
});
it('provides permission info methods', function () {
$attribute = new RequiresPermission('posts.edit', 'posts.admin');
expect($attribute->getPermissions())->toBe(['posts.edit', 'posts.admin']);
expect($attribute->getPrimaryPermission())->toBe('posts.edit');
expect($attribute->hasMultiplePermissions())->toBeTrue();
});
});
describe('SessionBasedAuthorizationChecker', function () {
it('identifies unauthenticated users', function () {
expect($this->authChecker->isAuthenticated())->toBeFalse();
expect($this->authChecker->getUserPermissions())->toBe([]);
});
it('identifies authenticated users', function () {
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view', 'posts.edit'],
]);
expect($this->authChecker->isAuthenticated())->toBeTrue();
expect($this->authChecker->getUserPermissions())->toBe(['posts.view', 'posts.edit']);
});
it('checks specific permission', function () {
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view', 'posts.edit'],
]);
expect($this->authChecker->hasPermission('posts.edit'))->toBeTrue();
expect($this->authChecker->hasPermission('posts.delete'))->toBeFalse();
});
it('allows access when no permission attribute present', function () {
$component = createTestComponent();
$isAuthorized = $this->authChecker->isAuthorized(
$component,
'someMethod',
null
);
expect($isAuthorized)->toBeTrue();
});
it('denies access for unauthenticated user with permission requirement', function () {
$component = createTestComponent();
$attribute = new RequiresPermission('posts.edit');
$isAuthorized = $this->authChecker->isAuthorized(
$component,
'editPost',
$attribute
);
expect($isAuthorized)->toBeFalse();
});
it('allows access when user has required permission', function () {
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view', 'posts.edit'],
]);
$component = createTestComponent();
$attribute = new RequiresPermission('posts.edit');
$isAuthorized = $this->authChecker->isAuthorized(
$component,
'editPost',
$attribute
);
expect($isAuthorized)->toBeTrue();
});
it('denies access when user lacks required permission', function () {
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view'],
]);
$component = createTestComponent();
$attribute = new RequiresPermission('posts.edit');
$isAuthorized = $this->authChecker->isAuthorized(
$component,
'editPost',
$attribute
);
expect($isAuthorized)->toBeFalse();
});
});
describe('LiveComponentHandler Authorization', function () {
it('executes action without permission requirement', function () {
$componentId = ComponentId::fromString('test:component');
// Generate CSRF token
$formId = 'livecomponent:' . $componentId->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$component = new class ($componentId) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray(['count' => 0]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
// Action without RequiresPermission attribute
public function increment(): ComponentData
{
return ComponentData::fromArray(['count' => 1]);
}
};
$params = ActionParameters::fromArray([], $csrfToken);
$result = $this->handler->handle($component, 'increment', $params);
expect($result->state->data['count'])->toBe(1);
});
it('throws exception for unauthenticated user with permission requirement', function () {
$componentId = ComponentId::fromString('test:component');
// Generate CSRF token
$formId = 'livecomponent:' . $componentId->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$component = new class ($componentId) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray([]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
#[RequiresPermission('posts.delete')]
public function deletePost(string $postId): ComponentData
{
return ComponentData::fromArray(['deleted' => true]);
}
};
$params = ActionParameters::fromArray(['postId' => '123'], $csrfToken);
expect(fn () => $this->handler->handle($component, 'deletePost', $params))
->toThrow(UnauthorizedActionException::class, 'requires authentication');
});
it('throws exception for user without required permission', function () {
// User with only 'posts.view' permission
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view'],
]);
$componentId = ComponentId::fromString('test:component');
// Generate CSRF token
$formId = 'livecomponent:' . $componentId->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$component = new class ($componentId) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray([]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
#[RequiresPermission('posts.delete')]
public function deletePost(string $postId): ComponentData
{
return ComponentData::fromArray(['deleted' => true]);
}
};
$params = ActionParameters::fromArray(['postId' => '123'], $csrfToken);
expect(fn () => $this->handler->handle($component, 'deletePost', $params))
->toThrow(UnauthorizedActionException::class, 'requires permission');
});
it('executes action when user has required permission', function () {
// User with 'posts.delete' permission
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.view', 'posts.delete'],
]);
$componentId = ComponentId::fromString('test:component');
// Generate CSRF token
$formId = 'livecomponent:' . $componentId->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$component = new class ($componentId) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray([]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
#[RequiresPermission('posts.delete')]
public function deletePost(string $postId): ComponentData
{
return ComponentData::fromArray(['deleted' => true, 'postId' => $postId]);
}
};
$params = ActionParameters::fromArray(['postId' => '456'], $csrfToken);
$result = $this->handler->handle($component, 'deletePost', $params);
expect($result->state->data['deleted'])->toBeTrue();
expect($result->state->data['postId'])->toBe('456');
});
it('supports multiple permissions with OR logic', function () {
// User has 'posts.admin' but not 'posts.edit'
$this->session->set('user', [
'id' => 123,
'permissions' => ['posts.admin'],
]);
$componentId = ComponentId::fromString('test:component');
// Generate CSRF token
$formId = 'livecomponent:' . $componentId->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$component = new class ($componentId) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray([]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
// Requires EITHER posts.edit OR posts.admin
#[RequiresPermission('posts.edit', 'posts.admin')]
public function editPost(string $postId): ComponentData
{
return ComponentData::fromArray(['edited' => true]);
}
};
$params = ActionParameters::fromArray(['postId' => '789'], $csrfToken);
$result = $this->handler->handle($component, 'editPost', $params);
expect($result->state->data['edited'])->toBeTrue();
});
});
describe('UnauthorizedActionException', function () {
it('provides user-friendly error messages', function () {
$exception = UnauthorizedActionException::forUnauthenticatedUser('PostsList', 'deletePost');
expect($exception->getUserMessage())->toBe('Please log in to perform this action');
expect($exception->isAuthenticationIssue())->toBeTrue();
});
it('includes missing permissions in context', function () {
$attribute = new RequiresPermission('posts.delete', 'posts.admin');
$userPermissions = ['posts.view', 'posts.edit'];
$exception = UnauthorizedActionException::forMissingPermission(
'PostsList',
'deletePost',
$attribute,
$userPermissions
);
expect($exception->getUserMessage())->toBe('You do not have permission to perform this action');
expect($exception->getMissingPermissions())->toBe(['posts.delete', 'posts.admin']);
expect($exception->isAuthenticationIssue())->toBeFalse();
});
});
// Helper function
function createTestComponent(): LiveComponentContract
{
return new class (ComponentId::fromString('test:component')) implements LiveComponentContract {
public function __construct(private ComponentId $id)
{
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray([]);
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
}