- 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.
519 lines
17 KiB
PHP
519 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Http\Session\SessionInterface;
|
|
use App\Framework\LiveComponents\ComponentEventDispatcher;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\LiveComponentHandler;
|
|
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentAction;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentData;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\Security\CsrfToken;
|
|
use App\Framework\View\LiveComponentRenderer;
|
|
use App\Framework\View\TemplateRenderer;
|
|
|
|
beforeEach(function () {
|
|
// Create mock SessionInterface for CSRF testing
|
|
$this->session = new class () implements SessionInterface {
|
|
private array $tokens = [];
|
|
|
|
public function __get(string $name): mixed
|
|
{
|
|
if ($name === 'csrf') {
|
|
return new class ($this) {
|
|
public function __construct(private $session)
|
|
{
|
|
}
|
|
|
|
public function generateToken(string $formId): CsrfToken
|
|
{
|
|
$token = CsrfToken::generate();
|
|
$this->session->tokens[$formId] = $token->toString();
|
|
|
|
return $token;
|
|
}
|
|
|
|
public function validateToken(string $formId, CsrfToken $token): bool
|
|
{
|
|
return isset($this->session->tokens[$formId])
|
|
&& hash_equals($this->session->tokens[$formId], $token->toString());
|
|
}
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function get(string $key, mixed $default = null): mixed
|
|
{
|
|
return $default;
|
|
}
|
|
|
|
public function set(string $key, mixed $value): void
|
|
{
|
|
}
|
|
|
|
public function has(string $key): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function remove(string $key): void
|
|
{
|
|
}
|
|
|
|
public function regenerate(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function destroy(): void
|
|
{
|
|
}
|
|
|
|
public function getId(): string
|
|
{
|
|
return 'test-session-id';
|
|
}
|
|
};
|
|
|
|
$this->eventDispatcher = new ComponentEventDispatcher();
|
|
$this->handler = new LiveComponentHandler($this->eventDispatcher, $this->session);
|
|
});
|
|
|
|
describe('CSRF Token Generation', function () {
|
|
it('generates unique CSRF token for each component instance', function () {
|
|
$renderer = new LiveComponentRenderer(
|
|
$this->createMock(TemplateRenderer::class),
|
|
$this->session
|
|
);
|
|
|
|
$html1 = $renderer->renderWithWrapper(
|
|
'counter:instance1',
|
|
'<div>Component 1</div>',
|
|
['count' => 0]
|
|
);
|
|
|
|
$html2 = $renderer->renderWithWrapper(
|
|
'counter:instance2',
|
|
'<div>Component 2</div>',
|
|
['count' => 0]
|
|
);
|
|
|
|
// Both should have CSRF tokens
|
|
expect($html1)->toContain('data-csrf-token');
|
|
expect($html2)->toContain('data-csrf-token');
|
|
|
|
// Extract tokens (they should be different)
|
|
preg_match('/data-csrf-token="([^"]+)"/', $html1, $matches1);
|
|
preg_match('/data-csrf-token="([^"]+)"/', $html2, $matches2);
|
|
|
|
expect($matches1[1])->not->toBe($matches2[1]);
|
|
});
|
|
|
|
it('generates CSRF token with correct formId pattern', function () {
|
|
$componentId = 'counter:test123';
|
|
$expectedFormId = 'livecomponent:counter:test123';
|
|
|
|
// Generate token using the session
|
|
$token = $this->session->csrf->generateToken($expectedFormId);
|
|
|
|
expect($token)->toBeInstanceOf(CsrfToken::class);
|
|
expect($token->toString())->toHaveLength(32);
|
|
});
|
|
|
|
it('includes CSRF token in rendered wrapper HTML', function () {
|
|
$renderer = new LiveComponentRenderer(
|
|
$this->createMock(TemplateRenderer::class),
|
|
$this->session
|
|
);
|
|
|
|
$html = $renderer->renderWithWrapper(
|
|
'counter:csrf-test',
|
|
'<div>Test Component</div>',
|
|
['count' => 42]
|
|
);
|
|
|
|
// Should contain data-csrf-token attribute
|
|
expect($html)->toMatch('/data-csrf-token="[a-f0-9]{32}"/');
|
|
|
|
// Should contain component ID and state
|
|
expect($html)->toContain('data-live-component="counter:csrf-test"');
|
|
expect($html)->toContain('data-state');
|
|
});
|
|
});
|
|
|
|
describe('CSRF Token Validation', function () {
|
|
it('validates correct CSRF token', function () {
|
|
$componentId = ComponentId::fromString('counter:validation-test');
|
|
$formId = 'livecomponent:counter:validation-test';
|
|
|
|
// Generate valid token
|
|
$csrfToken = $this->session->csrf->generateToken($formId);
|
|
|
|
// Create ActionParameters with valid token
|
|
$params = ActionParameters::fromArray([], $csrfToken);
|
|
|
|
// Create test component
|
|
$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 increment(): ComponentData
|
|
{
|
|
return ComponentData::fromArray(['count' => 1]);
|
|
}
|
|
};
|
|
|
|
// Should not throw exception
|
|
$result = $this->handler->handle($component, 'increment', $params);
|
|
expect($result)->not->toBeNull();
|
|
});
|
|
|
|
it('rejects request without CSRF token', function () {
|
|
$componentId = ComponentId::fromString('counter:no-token-test');
|
|
|
|
// Create ActionParameters WITHOUT token
|
|
$params = ActionParameters::fromArray([]);
|
|
|
|
$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 action(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
};
|
|
|
|
expect(fn () => $this->handler->handle($component, 'action', $params))
|
|
->toThrow(\InvalidArgumentException::class, 'CSRF token is required');
|
|
});
|
|
|
|
it('rejects request with invalid CSRF token', function () {
|
|
$componentId = ComponentId::fromString('counter:invalid-token-test');
|
|
|
|
// Create INVALID token (not generated by session)
|
|
$invalidToken = CsrfToken::fromString(bin2hex(random_bytes(16)));
|
|
$params = ActionParameters::fromArray([], $invalidToken);
|
|
|
|
$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 action(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
};
|
|
|
|
expect(fn () => $this->handler->handle($component, 'action', $params))
|
|
->toThrow(\RuntimeException::class, 'CSRF token validation failed');
|
|
});
|
|
|
|
it('validates CSRF token for different component instances separately', function () {
|
|
// Generate token for component A
|
|
$componentIdA = ComponentId::fromString('counter:instanceA');
|
|
$formIdA = 'livecomponent:counter:instanceA';
|
|
$tokenA = $this->session->csrf->generateToken($formIdA);
|
|
|
|
// Try to use token A with component B (should fail)
|
|
$componentIdB = ComponentId::fromString('counter:instanceB');
|
|
$params = ActionParameters::fromArray([], $tokenA);
|
|
|
|
$componentB = new class ($componentIdB) implements LiveComponentContract {
|
|
public function __construct(private ComponentId $id)
|
|
{
|
|
}
|
|
|
|
public function getId(): ComponentId
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getData(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
|
|
public function action(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
};
|
|
|
|
expect(fn () => $this->handler->handle($componentB, 'action', $params))
|
|
->toThrow(\RuntimeException::class, 'CSRF token validation failed');
|
|
});
|
|
});
|
|
|
|
describe('ComponentAction CSRF Extraction', function () {
|
|
it('extracts CSRF token from request body', function () {
|
|
$mockRequest = new class () {
|
|
public ?object $parsedBody = null;
|
|
|
|
public object $headers;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->parsedBody = (object)[
|
|
'toArray' => fn () => [
|
|
'component_id' => 'counter:test',
|
|
'method' => 'increment',
|
|
'_csrf_token' => '0123456789abcdef0123456789abcdef',
|
|
'params' => [],
|
|
],
|
|
];
|
|
|
|
$this->headers = new class () {
|
|
public function getFirst(string $key): ?string
|
|
{
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
|
|
$action = ComponentAction::fromRequest($mockRequest);
|
|
|
|
expect($action->params->hasCsrfToken())->toBeTrue();
|
|
expect($action->params->getCsrfToken()->toString())->toBe('0123456789abcdef0123456789abcdef');
|
|
});
|
|
|
|
it('extracts CSRF token from X-CSRF-Token header', function () {
|
|
$mockRequest = new class () {
|
|
public ?object $parsedBody = null;
|
|
|
|
public object $headers;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->parsedBody = (object)[
|
|
'toArray' => fn () => [
|
|
'component_id' => 'counter:test',
|
|
'method' => 'increment',
|
|
'params' => [],
|
|
],
|
|
];
|
|
|
|
$this->headers = new class () {
|
|
public function getFirst(string $key): ?string
|
|
{
|
|
return $key === 'X-CSRF-Token'
|
|
? 'fedcba9876543210fedcba9876543210'
|
|
: null;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
|
|
$action = ComponentAction::fromRequest($mockRequest);
|
|
|
|
expect($action->params->hasCsrfToken())->toBeTrue();
|
|
expect($action->params->getCsrfToken()->toString())->toBe('fedcba9876543210fedcba9876543210');
|
|
});
|
|
|
|
it('handles missing CSRF token gracefully', function () {
|
|
$mockRequest = new class () {
|
|
public ?object $parsedBody = null;
|
|
|
|
public object $headers;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->parsedBody = (object)[
|
|
'toArray' => fn () => [
|
|
'component_id' => 'counter:test',
|
|
'method' => 'increment',
|
|
'params' => [],
|
|
],
|
|
];
|
|
|
|
$this->headers = new class () {
|
|
public function getFirst(string $key): ?string
|
|
{
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
|
|
$action = ComponentAction::fromRequest($mockRequest);
|
|
|
|
expect($action->params->hasCsrfToken())->toBeFalse();
|
|
expect($action->params->getCsrfToken())->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('File Upload CSRF Protection', function () {
|
|
it('validates CSRF token for file uploads', function () {
|
|
$componentId = ComponentId::fromString('uploader:test');
|
|
$formId = 'livecomponent:uploader:test';
|
|
|
|
// Generate valid token
|
|
$csrfToken = $this->session->csrf->generateToken($formId);
|
|
$params = ActionParameters::fromArray([], $csrfToken);
|
|
|
|
// Mock UploadedFile
|
|
$file = new class () {
|
|
public string $name = 'test.txt';
|
|
|
|
public int $size = 1024;
|
|
|
|
public string $tmpName = '/tmp/test';
|
|
|
|
public int $error = 0;
|
|
};
|
|
|
|
// Create component that supports file upload
|
|
$component = new class ($componentId) implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\SupportsFileUpload {
|
|
public function __construct(private ComponentId $id)
|
|
{
|
|
}
|
|
|
|
public function getId(): ComponentId
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getData(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
|
|
public function handleUpload($file, ActionParameters $params, ComponentEventDispatcher $dispatcher): ComponentData
|
|
{
|
|
return ComponentData::fromArray(['uploaded' => true]);
|
|
}
|
|
};
|
|
|
|
// Should not throw exception
|
|
$result = $this->handler->handleUpload($component, $file, $params);
|
|
expect($result)->not->toBeNull();
|
|
});
|
|
|
|
it('rejects file upload without CSRF token', function () {
|
|
$componentId = ComponentId::fromString('uploader:no-token');
|
|
$params = ActionParameters::fromArray([]);
|
|
|
|
$file = new class () {
|
|
public string $name = 'test.txt';
|
|
};
|
|
|
|
$component = new class ($componentId) implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\SupportsFileUpload {
|
|
public function __construct(private ComponentId $id)
|
|
{
|
|
}
|
|
|
|
public function getId(): ComponentId
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getData(): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
|
|
public function handleUpload($file, ActionParameters $params, ComponentEventDispatcher $dispatcher): ComponentData
|
|
{
|
|
return ComponentData::fromArray([]);
|
|
}
|
|
};
|
|
|
|
expect(fn () => $this->handler->handleUpload($component, $file, $params))
|
|
->toThrow(\InvalidArgumentException::class, 'CSRF token is required');
|
|
});
|
|
});
|
|
|
|
describe('End-to-End CSRF Flow', function () {
|
|
it('completes full CSRF flow from render to action execution', function () {
|
|
// Step 1: Render component with CSRF token
|
|
$renderer = new LiveComponentRenderer(
|
|
$this->createMock(TemplateRenderer::class),
|
|
$this->session
|
|
);
|
|
|
|
$componentId = 'counter:e2e-test';
|
|
$html = $renderer->renderWithWrapper(
|
|
$componentId,
|
|
'<div>Counter: 0</div>',
|
|
['count' => 0]
|
|
);
|
|
|
|
// Step 2: Extract CSRF token from rendered HTML
|
|
preg_match('/data-csrf-token="([^"]+)"/', $html, $matches);
|
|
expect($matches)->toHaveCount(2);
|
|
$csrfTokenString = $matches[1];
|
|
|
|
// Step 3: Simulate client sending action with CSRF token
|
|
$csrfToken = CsrfToken::fromString($csrfTokenString);
|
|
$params = ActionParameters::fromArray(['amount' => 1], $csrfToken);
|
|
|
|
// Step 4: Execute action with CSRF validation
|
|
$component = new class (ComponentId::fromString($componentId)) implements LiveComponentContract {
|
|
public function __construct(private ComponentId $id, private int $count = 0)
|
|
{
|
|
}
|
|
|
|
public function getId(): ComponentId
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getData(): ComponentData
|
|
{
|
|
return ComponentData::fromArray(['count' => $this->count]);
|
|
}
|
|
|
|
public function increment(int $amount): ComponentData
|
|
{
|
|
$this->count += $amount;
|
|
|
|
return $this->getData();
|
|
}
|
|
};
|
|
|
|
$result = $this->handler->handle($component, 'increment', $params);
|
|
|
|
// Step 5: Verify action executed successfully
|
|
expect($result)->not->toBeNull();
|
|
expect($result->state->data['count'])->toBe(1);
|
|
});
|
|
});
|