Files
michaelschiemer/tests/Feature/Framework/LiveComponents/CsrfIntegrationTest.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

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