- 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.
428 lines
15 KiB
PHP
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', []);
|
|
}
|
|
};
|
|
}
|