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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Filesystem\FileStorage;
use App\Framework\LiveComponents\Services\ChunkedUploadManager;
use App\Framework\LiveComponents\Services\ChunkAssembler;
use App\Framework\LiveComponents\Services\IntegrityValidator;
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
use App\Framework\Random\RandomGenerator;
use Tests\Support\InMemoryUploadProgressTracker;
use Tests\Support\InMemoryUploadSessionStore;
describe('ChunkedUploadManager Integration', function () {
beforeEach(function () {
// Setup test directory
$this->testDir = sys_get_temp_dir() . '/chunked_upload_test_' . uniqid();
mkdir($this->testDir);
// Setup dependencies
$this->randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$this->sessionIdGenerator = new UploadSessionIdGenerator($this->randomGen);
$this->sessionStore = new InMemoryUploadSessionStore();
$this->integrityValidator = new IntegrityValidator();
$this->fileStorage = new FileStorage();
$this->chunkAssembler = new ChunkAssembler($this->fileStorage);
$this->progressTracker = new InMemoryUploadProgressTracker();
$this->uploadManager = new ChunkedUploadManager(
sessionIdGenerator: $this->sessionIdGenerator,
sessionStore: $this->sessionStore,
integrityValidator: $this->integrityValidator,
chunkAssembler: $this->chunkAssembler,
fileStorage: $this->fileStorage,
progressTracker: $this->progressTracker,
uploadBasePath: $this->testDir
);
});
afterEach(function () {
// Cleanup test directory
if (is_dir($this->testDir)) {
$files = glob($this->testDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
} elseif (is_dir($file)) {
$subFiles = glob($file . '/*');
foreach ($subFiles as $subFile) {
if (is_file($subFile)) {
unlink($subFile);
}
}
rmdir($file);
}
}
rmdir($this->testDir);
}
});
it('initializes upload session', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromKilobytes(10),
chunkSize: Byte::fromKilobytes(2)
);
expect($session->componentId)->toBe('test-component');
expect($session->fileName)->toBe('test.pdf');
expect($session->totalSize->toKilobytes())->toBe(10.0);
expect($session->totalChunks)->toBe(5); // 10KB / 2KB = 5 chunks
expect($session->isComplete())->toBe(false);
// Verify session is persisted
$retrieved = $this->uploadManager->getStatus($session->sessionId);
expect($retrieved !== null)->toBe(true);
expect($retrieved->sessionId->equals($session->sessionId))->toBeTrue();
});
it('initializes with SSE broadcast', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromKilobytes(10),
chunkSize: Byte::fromKilobytes(2),
userId: 'user123'
);
$broadcasts = $this->progressTracker->getBroadcasts();
expect(count($broadcasts))->toBe(1);
expect($broadcasts[0]['type'])->toBe('initialized');
expect($broadcasts[0]['user_id'])->toBe('user123');
});
it('uploads chunks sequentially', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
$chunk1Data = str_repeat('A', 100);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$updatedSession = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunk1Data,
providedHash: $chunk1Hash
);
expect(count($updatedSession->getUploadedChunks()))->toBe(1);
expect($updatedSession->getProgress())->toBeGreaterThan(0);
expect($updatedSession->isComplete())->toBe(false);
// Upload second chunk
$chunk2Data = str_repeat('B', 100);
$chunk2Hash = ChunkHash::fromData($chunk2Data);
$updatedSession = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 1,
chunkData: $chunk2Data,
providedHash: $chunk2Hash
);
expect(count($updatedSession->getUploadedChunks()))->toBe(2);
});
it('uploads chunks with SSE broadcasts', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100),
userId: 'user123'
);
$this->progressTracker->clear();
$chunk1Data = str_repeat('A', 100);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunk1Data,
providedHash: $chunk1Hash,
userId: 'user123'
);
expect($this->progressTracker->getBroadcastCount('chunk_uploaded'))->toBe(1);
});
it('completes upload and assembles file', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
// Upload all chunks
for ($i = 0; $i < 3; $i++) {
$chunkData = str_repeat(chr(65 + $i), 100); // A, B, C
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: $i,
chunkData: $chunkData,
providedHash: $chunkHash
);
}
// Complete upload
$targetPath = $this->testDir . '/assembled.txt';
$completedSession = $this->uploadManager->completeUpload(
sessionId: $session->sessionId,
targetPath: $targetPath
);
expect($completedSession->isComplete())->toBe(true);
expect($completedSession->completedAt !== null)->toBe(true);
expect(file_exists($targetPath))->toBeTrue();
$content = file_get_contents($targetPath);
expect(strlen($content))->toBe(300);
expect(substr($content, 0, 100))->toBe(str_repeat('A', 100));
expect(substr($content, 100, 100))->toBe(str_repeat('B', 100));
expect(substr($content, 200, 100))->toBe(str_repeat('C', 100));
});
it('validates final file hash', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100),
expectedFileHash: ChunkHash::fromData(str_repeat('A', 100) . str_repeat('B', 100))
);
// Upload chunks
$chunk1Data = str_repeat('A', 100);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash);
$chunk2Data = str_repeat('B', 100);
$chunk2Hash = ChunkHash::fromData($chunk2Data);
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash);
// Complete upload - should succeed with matching hash
$targetPath = $this->testDir . '/assembled.txt';
$completedSession = $this->uploadManager->completeUpload($session->sessionId, $targetPath);
expect($completedSession->isComplete())->toBe(true);
expect(file_exists($targetPath))->toBeTrue();
});
it('rejects mismatched final file hash', function () {
$wrongHash = ChunkHash::fromData('wrong content');
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100),
expectedFileHash: $wrongHash
);
// Upload chunks
$chunk1Data = str_repeat('A', 100);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash);
$chunk2Data = str_repeat('B', 100);
$chunk2Hash = ChunkHash::fromData($chunk2Data);
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash);
// Complete upload - should fail with hash mismatch
$targetPath = $this->testDir . '/assembled.txt';
expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath))
->toThrow(InvalidArgumentException::class);
// Verify file was cleaned up after hash mismatch
expect(file_exists($targetPath))->toBe(false);
});
it('rejects chunk with invalid hash', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100)
);
$chunkData = str_repeat('A', 100);
$wrongHash = ChunkHash::fromData('wrong data');
expect(fn() => $this->uploadManager->uploadChunk(
$session->sessionId,
0,
$chunkData,
$wrongHash
))->toThrow(InvalidArgumentException::class);
});
it('rejects chunk for non-existent session', function () {
$nonExistentSessionId = $this->sessionIdGenerator->generate();
$chunkData = str_repeat('A', 100);
$chunkHash = ChunkHash::fromData($chunkData);
expect(fn() => $this->uploadManager->uploadChunk(
$nonExistentSessionId,
0,
$chunkData,
$chunkHash
))->toThrow(InvalidArgumentException::class);
});
it('rejects chunk for expired session', function () {
$sessionId = $this->sessionIdGenerator->generate();
// Create expired session
$expiredSession = new \App\Framework\LiveComponents\ValueObjects\UploadSession(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
totalChunks: 2,
createdAt: new DateTimeImmutable('-2 hours'),
expiresAt: new DateTimeImmutable('-1 hour')
);
$this->sessionStore->save($expiredSession);
$chunkData = str_repeat('A', 100);
$chunkHash = ChunkHash::fromData($chunkData);
expect(fn() => $this->uploadManager->uploadChunk(
$sessionId,
0,
$chunkData,
$chunkHash
))->toThrow(InvalidArgumentException::class);
});
it('rejects invalid chunk index', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100)
);
$chunkData = str_repeat('A', 100);
$chunkHash = ChunkHash::fromData($chunkData);
// Chunk index out of range (totalChunks = 2, so valid indices are 0,1)
expect(fn() => $this->uploadManager->uploadChunk(
$session->sessionId,
5,
$chunkData,
$chunkHash
))->toThrow(InvalidArgumentException::class);
});
it('prevents completion with missing chunks', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
// Upload only first chunk (missing chunks 1 and 2)
$chunkData = str_repeat('A', 100);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash);
$targetPath = $this->testDir . '/assembled.txt';
expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath))
->toThrow(InvalidArgumentException::class);
});
it('aborts upload and cleans up', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
// Upload one chunk
$chunkData = str_repeat('A', 100);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash);
// Abort upload
$this->uploadManager->abortUpload($session->sessionId);
// Verify session is deleted
expect($this->uploadManager->getStatus($session->sessionId))->toBeNull();
// Verify chunks are cleaned up
expect($this->sessionStore->exists($session->sessionId))->toBe(false);
});
it('aborts with SSE broadcast', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100),
userId: 'user123'
);
$this->progressTracker->clear();
$this->uploadManager->abortUpload($session->sessionId, userId: 'user123', reason: 'Test abort');
expect($this->progressTracker->getBroadcastCount('aborted'))->toBe(1);
$abortBroadcasts = $this->progressTracker->getBroadcastsByType('aborted');
expect($abortBroadcasts[0]['data']['reason'])->toBe('Test abort');
});
it('handles resume capability', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
// Upload chunks 0 and 2 (skip chunk 1)
$chunk0Data = str_repeat('A', 100);
$chunk0Hash = ChunkHash::fromData($chunk0Data);
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk0Data, $chunk0Hash);
$chunk2Data = str_repeat('C', 100);
$chunk2Hash = ChunkHash::fromData($chunk2Data);
$this->uploadManager->uploadChunk($session->sessionId, 2, $chunk2Data, $chunk2Hash);
// Get status to see missing chunks
$currentSession = $this->uploadManager->getStatus($session->sessionId);
$missingChunks = $currentSession->getMissingChunkIndices();
expect($missingChunks)->toBe([1]);
// Upload missing chunk
$chunk1Data = str_repeat('B', 100);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk1Data, $chunk1Hash);
// Now should be complete
$finalSession = $this->uploadManager->getStatus($session->sessionId);
expect($finalSession->isComplete())->toBe(true);
});
it('creates session directory on initialization', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(100)
);
$sessionPath = $this->testDir . '/' . $session->sessionId->toString();
expect(is_dir($sessionPath))->toBeTrue();
});
it('validates empty file size', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(0),
chunkSize: Byte::fromBytes(100)
))->toThrow(InvalidArgumentException::class);
});
it('validates empty chunk size', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-component',
fileName: 'test.txt',
totalSize: Byte::fromBytes(200),
chunkSize: Byte::fromBytes(0)
))->toThrow(InvalidArgumentException::class);
});
});

View File

@@ -0,0 +1,852 @@
<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\DateTime\SystemHighResolutionClock;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\ParameterBinding\ParameterBinder;
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
use App\Framework\LiveComponents\Validation\SchemaCache;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\Performance\ActionProfiler;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\NestedPerformanceTracker;
use App\Framework\Performance\PerformanceCategory;
/**
* Integration Tests for LiveComponent Performance Profiling
*
* Tests the integration between:
* - LiveComponentHandler
* - NestedPerformanceTracker
* - ActionProfiler
* - Component Lifecycle Profiling
*/
describe('LiveComponent Profiling Integration', function () {
beforeEach(function () {
// Initialize performance tracking infrastructure
$this->tracker = new NestedPerformanceTracker(
new SystemClock(),
new SystemHighResolutionClock(),
new MemoryMonitor()
);
$this->profiler = new ActionProfiler($this->tracker);
// Mock dependencies for LiveComponentHandler
$this->eventDispatcher = new ComponentEventDispatcher();
// Simple anonymous class mocks instead of Mockery (to avoid readonly property issues)
$this->session = new class implements \App\Framework\Http\Session\SessionInterface {
public readonly object $csrf;
public readonly object $validation;
public readonly object $form;
public function __construct()
{
$this->csrf = new class {
public function validateToken(string $formId, string $token): bool
{
return true;
}
};
$this->validation = new class {};
$this->form = new class {};
}
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 clear(): void {}
public function all(): array { return []; }
public function getId(): \App\Framework\Http\Session\SessionId { return new \App\Framework\Http\Session\SessionId('test-session-id'); }
public static function fromArray(\App\Framework\Http\Session\SessionId $sessionId, \App\Framework\DateTime\Clock $clock, \App\Framework\Security\CsrfTokenGenerator $csrfTokenGenerator, array $data): self { return new self(); }
};
$this->authChecker = new class implements \App\Framework\LiveComponents\Security\ActionAuthorizationChecker {
public function isAuthorized($component, string $method, $permissionAttribute): bool
{
return true;
}
public function getUserPermissions(): array
{
return [];
}
public function hasPermission(string $permission): bool
{
return true;
}
public function isAuthenticated(): bool
{
return true;
}
};
$this->schemaCache = new SchemaCache();
$this->rateLimiter = new class {
public function checkActionLimit($component, string $method, string $clientId, $actionAttr): \App\Framework\LiveComponents\ValueObjects\RateLimitResult
{
return new \App\Framework\LiveComponents\ValueObjects\RateLimitResult(
allowed: true,
limit: 100,
current: 1,
retryAfter: null
);
}
};
$this->idempotency = new class {
public function execute(mixed $key, callable $operation, mixed $ttl): mixed
{
return $operation();
}
};
$this->parameterBinder = new class {
public function bindParameters(\ReflectionMethod $method, ActionParameters $params): array
{
return [];
}
};
$this->frameworkDispatcher = new class {
public function dispatch($event): void {}
};
// Create handler with performance tracker
$this->handler = new LiveComponentHandler(
$this->eventDispatcher,
$this->session,
$this->authChecker,
$this->schemaCache,
$this->rateLimiter,
$this->idempotency,
$this->parameterBinder,
$this->frameworkDispatcher,
$this->tracker // PerformanceTracker injected here
);
});
it('tracks performance for component action execution', function () {
// Create test component
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('test-counter', 'profiling-1');
$this->state = new class {
public int $count = 0;
public function toArray(): array
{
return ['count' => $this->count];
}
};
}
#[Action]
public function increment(): object
{
// Simulate some work
usleep(5000); // 5ms
$newState = clone $this->state;
$newState->count++;
return $newState;
}
};
// Execute action
$params = ActionParameters::create([
'_csrf_token' => 'valid-token'
]);
$this->handler->handle($component, 'increment', $params);
// Verify performance tracking
$timeline = $this->tracker->generateTimeline();
expect($timeline)->toBeArray();
expect($timeline)->not->toBeEmpty();
// Find the main component action measurement
$componentMeasurements = array_filter($timeline, function ($event) {
return str_contains($event['name'], 'livecomponent.test-counter.increment');
});
expect($componentMeasurements)->not->toBeEmpty();
$mainMeasurement = array_values($componentMeasurements)[0];
expect($mainMeasurement['category'])->toBe('custom');
expect($mainMeasurement['duration_ms'])->toBeGreaterThan(4); // At least 5ms from usleep
});
it('tracks nested profiling for schema derivation', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('schema-test', 'nested-1');
$this->state = new class {
public string $value = 'test';
public function toArray(): array
{
return ['value' => $this->value];
}
};
}
#[Action]
public function update(): object
{
return $this->state;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$this->handler->handle($component, 'update', $params);
$timeline = $this->tracker->generateTimeline();
// Should have nested measurements for:
// 1. Main component action
// 2. Schema derivation
// 3. Action execution
// 4. State validation
$schemaMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'schema.derive'));
$actionMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'action.execute'));
$validationMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'state.validate'));
expect($schemaMeasurements)->not->toBeEmpty();
expect($actionMeasurements)->not->toBeEmpty();
expect($validationMeasurements)->not->toBeEmpty();
});
it('tracks lifecycle hook performance (onMount)', function () {
$component = new class implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\LifecycleAware {
public ComponentId $id;
public object $state;
public bool $mountCalled = false;
public function __construct()
{
$this->id = ComponentId::create('lifecycle-test', 'mount-1');
$this->state = new class {
public array $data = [];
public function toArray(): array
{
return $this->data;
}
};
}
public function onMount(): void
{
// Simulate initialization work
usleep(2000); // 2ms
$this->mountCalled = true;
}
public function onUpdate(): void
{
// Not tested here
}
#[Action]
public function test(): object
{
return $this->state;
}
};
// Call onMount via handler
$this->handler->callMountHook($component);
expect($component->mountCalled)->toBeTrue();
$timeline = $this->tracker->generateTimeline();
$mountMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'lifecycle.onMount'));
expect($mountMeasurements)->not->toBeEmpty();
$mountMeasurement = array_values($mountMeasurements)[0];
expect($mountMeasurement['duration_ms'])->toBeGreaterThan(1); // At least 2ms
expect($mountMeasurement['context'])->toHaveKey('component');
expect($mountMeasurement['context']['component'])->toBe('lifecycle-test');
});
it('tracks lifecycle hook performance (onUpdate)', function () {
$component = new class implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\LifecycleAware {
public ComponentId $id;
public object $state;
public bool $updateCalled = false;
public function __construct()
{
$this->id = ComponentId::create('lifecycle-test', 'update-1');
$this->state = new class {
public int $counter = 0;
public function toArray(): array
{
return ['counter' => $this->counter];
}
};
}
public function onMount(): void
{
// Not tested here
}
public function onUpdate(): void
{
// Simulate update work
usleep(3000); // 3ms
$this->updateCalled = true;
}
#[Action]
public function increment(): object
{
$newState = clone $this->state;
$newState->counter++;
return $newState;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$this->handler->handle($component, 'increment', $params);
expect($component->updateCalled)->toBeTrue();
$timeline = $this->tracker->generateTimeline();
$updateMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'lifecycle.onUpdate'));
expect($updateMeasurements)->not->toBeEmpty();
$updateMeasurement = array_values($updateMeasurements)[0];
expect($updateMeasurement['duration_ms'])->toBeGreaterThan(2); // At least 3ms
});
it('provides context data for all profiled operations', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('context-component', 'ctx-1');
$this->state = new class {
public string $name = 'test';
public function toArray(): array
{
return ['name' => $this->name];
}
};
}
#[Action]
public function doSomething(): object
{
usleep(1000);
return $this->state;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$this->handler->handle($component, 'doSomething', $params);
$timeline = $this->tracker->generateTimeline();
// Check that context is provided for measurements
foreach ($timeline as $event) {
if (str_contains($event['name'], 'livecomponent.context-component')) {
expect($event)->toHaveKey('context');
expect($event['context'])->toHaveKey('component');
expect($event['context']['component'])->toBe('context-component');
if (str_contains($event['name'], 'livecomponent.context-component.doSomething')) {
expect($event['context'])->toHaveKey('action');
expect($event['context']['action'])->toBe('doSomething');
}
}
}
});
it('tracks memory usage for component actions', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('memory-test', 'mem-1');
$this->state = new class {
public array $data = [];
public function toArray(): array
{
return $this->data;
}
};
}
#[Action]
public function allocateMemory(): object
{
// Allocate some memory
$data = array_fill(0, 5000, str_repeat('x', 100)); // ~0.5MB
$newState = clone $this->state;
$newState->data = $data;
return $newState;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$this->handler->handle($component, 'allocateMemory', $params);
$timeline = $this->tracker->generateTimeline();
// Find component action measurement
$componentMeasurements = array_filter($timeline, function ($event) {
return str_contains($event['name'], 'livecomponent.memory-test.allocateMemory');
});
expect($componentMeasurements)->not->toBeEmpty();
$measurement = array_values($componentMeasurements)[0];
// Should have memory tracking
expect($measurement)->toHaveKey('memory_delta_mb');
expect($measurement['memory_delta_mb'])->toBeGreaterThan(0);
});
it('integrates with ActionProfiler for detailed metrics', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('profiler-test', 'prof-1');
$this->state = new class {
public int $value = 0;
public function toArray(): array
{
return ['value' => $this->value];
}
};
}
#[Action]
public function process(): object
{
usleep(10000); // 10ms
return $this->state;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
// Execute action multiple times
for ($i = 0; $i < 3; $i++) {
$this->handler->handle($component, 'process', $params);
$this->tracker->reset(); // Reset for next execution
}
// Note: ActionProfiler methods would need to be used here
// This test verifies the integration point exists
expect($this->profiler)->toBeInstanceOf(ActionProfiler::class);
});
it('handles fast actions without overhead', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('fast-action', 'fast-1');
$this->state = new class {
public bool $flag = false;
public function toArray(): array
{
return ['flag' => $this->flag];
}
};
}
#[Action]
public function toggle(): object
{
// Very fast action - no usleep
$newState = clone $this->state;
$newState->flag = !$newState->flag;
return $newState;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$start = microtime(true);
$this->handler->handle($component, 'toggle', $params);
$duration = (microtime(true) - $start) * 1000;
// Performance tracking should add minimal overhead (<5ms for very fast actions)
expect($duration)->toBeLessThan(10);
$timeline = $this->tracker->generateTimeline();
expect($timeline)->not->toBeEmpty();
});
it('tracks performance categories correctly', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('category-test', 'cat-1');
$this->state = new class {
public array $items = [];
public function toArray(): array
{
return ['items' => $this->items];
}
};
}
#[Action]
public function load(): object
{
return $this->state;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
$this->handler->handle($component, 'load', $params);
$timeline = $this->tracker->generateTimeline();
// Verify categories
$categoryCounts = [];
foreach ($timeline as $event) {
$category = $event['category'];
$categoryCounts[$category] = ($categoryCounts[$category] ?? 0) + 1;
}
// Should have:
// - CUSTOM for main component action
// - CACHE for schema derivation
expect($categoryCounts)->toHaveKey('custom');
expect($categoryCounts)->toHaveKey('cache');
});
});
describe('LiveComponent Profiling - Error Scenarios', function () {
beforeEach(function () {
$this->tracker = new NestedPerformanceTracker(
new SystemClock(),
new SystemHighResolutionClock(),
new MemoryMonitor()
);
$this->profiler = new ActionProfiler($this->tracker);
$this->eventDispatcher = new ComponentEventDispatcher();
// Simple mocks (copy from first describe block)
$this->session = new class {
public readonly object $csrf;
public readonly object $validation;
public readonly object $form;
public function __construct()
{
$this->csrf = new class {
public function validateToken(string $formId, string $token): bool { return true; }
};
$this->validation = new class {};
$this->form = new class {};
}
};
$this->authChecker = new class implements \App\Framework\LiveComponents\Security\ActionAuthorizationChecker {
public function isAuthorized($component, string $method, $permissionAttribute): bool { return true; }
public function getUserPermissions(): array { return []; }
public function hasPermission(string $permission): bool { return true; }
public function isAuthenticated(): bool { return true; }
};
$this->schemaCache = new SchemaCache();
$this->rateLimiter = new class {
public function checkActionLimit($component, string $method, string $clientId, $actionAttr): \App\Framework\LiveComponents\ValueObjects\RateLimitResult {
return new \App\Framework\LiveComponents\ValueObjects\RateLimitResult(allowed: true, limit: 100, current: 1, retryAfter: null);
}
};
$this->idempotency = new class {
public function execute(mixed $key, callable $operation, mixed $ttl): mixed { return $operation(); }
};
$this->parameterBinder = new class {
public function bindParameters(\ReflectionMethod $method, ActionParameters $params): array { return []; }
};
$this->frameworkDispatcher = new class {
public function dispatch($event): void {}
};
$this->handler = new LiveComponentHandler(
$this->eventDispatcher,
$this->session,
$this->authChecker,
$this->schemaCache,
$this->rateLimiter,
$this->idempotency,
$this->parameterBinder,
$this->frameworkDispatcher,
$this->tracker
);
});
it('tracks performance even when action throws exception', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('error-test', 'err-1');
$this->state = new class {
public function toArray(): array
{
return [];
}
};
}
#[Action]
public function failing(): object
{
usleep(3000); // 3ms before error
throw new \RuntimeException('Action failed');
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
try {
$this->handler->handle($component, 'failing', $params);
expect(true)->toBeFalse('Should have thrown exception');
} catch (\RuntimeException $e) {
expect($e->getMessage())->toBe('Action failed');
}
// Performance should still be tracked even though action failed
$timeline = $this->tracker->generateTimeline();
$actionMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'action.execute'));
expect($actionMeasurements)->not->toBeEmpty();
// The execution time should reflect the work done before the exception
$measurement = array_values($actionMeasurements)[0];
expect($measurement['duration_ms'])->toBeGreaterThan(2);
});
it('handles lifecycle hook errors gracefully while still tracking', function () {
$component = new class implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\LifecycleAware {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('lifecycle-error', 'err-2');
$this->state = new class {
public function toArray(): array
{
return [];
}
};
}
public function onMount(): void
{
usleep(1000);
throw new \Exception('Mount failed');
}
public function onUpdate(): void
{
// Not tested
}
#[Action]
public function test(): object
{
return $this->state;
}
};
// Should not throw - lifecycle hook errors are caught
$this->handler->callMountHook($component);
$timeline = $this->tracker->generateTimeline();
// Performance tracking should still work
$mountMeasurements = array_filter($timeline, fn($e) => str_contains($e['name'], 'lifecycle.onMount'));
expect($mountMeasurements)->not->toBeEmpty();
});
});
describe('LiveComponent Profiling - Performance Benchmarks', function () {
beforeEach(function () {
$this->tracker = new NestedPerformanceTracker(
new SystemClock(),
new SystemHighResolutionClock(),
new MemoryMonitor()
);
$this->profiler = new ActionProfiler($this->tracker);
$this->eventDispatcher = new ComponentEventDispatcher();
// Simple mocks (copy from first describe block)
$this->session = new class {
public readonly object $csrf;
public readonly object $validation;
public readonly object $form;
public function __construct()
{
$this->csrf = new class {
public function validateToken(string $formId, string $token): bool { return true; }
};
$this->validation = new class {};
$this->form = new class {};
}
};
$this->authChecker = new class implements \App\Framework\LiveComponents\Security\ActionAuthorizationChecker {
public function isAuthorized($component, string $method, $permissionAttribute): bool { return true; }
public function getUserPermissions(): array { return []; }
public function hasPermission(string $permission): bool { return true; }
public function isAuthenticated(): bool { return true; }
};
$this->schemaCache = new SchemaCache();
$this->rateLimiter = new class {
public function checkActionLimit($component, string $method, string $clientId, $actionAttr): \App\Framework\LiveComponents\ValueObjects\RateLimitResult {
return new \App\Framework\LiveComponents\ValueObjects\RateLimitResult(allowed: true, limit: 100, current: 1, retryAfter: null);
}
};
$this->idempotency = new class {
public function execute(mixed $key, callable $operation, mixed $ttl): mixed { return $operation(); }
};
$this->parameterBinder = new class {
public function bindParameters(\ReflectionMethod $method, ActionParameters $params): array { return []; }
};
$this->frameworkDispatcher = new class {
public function dispatch($event): void {}
};
$this->handler = new LiveComponentHandler(
$this->eventDispatcher,
$this->session,
$this->authChecker,
$this->schemaCache,
$this->rateLimiter,
$this->idempotency,
$this->parameterBinder,
$this->frameworkDispatcher,
$this->tracker
);
});
it('measures overhead of profiling system', function () {
$component = new class implements LiveComponentContract {
public ComponentId $id;
public object $state;
public function __construct()
{
$this->id = ComponentId::create('benchmark', 'bench-1');
$this->state = new class {
public int $counter = 0;
public function toArray(): array
{
return ['counter' => $this->counter];
}
};
}
#[Action]
public function noop(): object
{
// Minimal work - just to measure overhead
return $this->state;
}
};
$params = ActionParameters::create(['_csrf_token' => 'valid-token']);
// Execute multiple times to get average
$executions = 100;
$totalTime = 0;
for ($i = 0; $i < $executions; $i++) {
$start = microtime(true);
$this->handler->handle($component, 'noop', $params);
$totalTime += (microtime(true) - $start) * 1000;
$this->tracker->reset();
}
$avgTime = $totalTime / $executions;
// Average execution time should be reasonable (<5ms per action with profiling)
expect($avgTime)->toBeLessThan(5);
// Should be able to handle 200+ actions per second
$actionsPerSecond = 1000 / $avgTime;
expect($actionsPerSecond)->toBeGreaterThan(200);
});
});

View File

@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
/**
* State Encryption Integration Tests
*
* Integration tests for state encryption with security layers.
* Tests the complete encryption flow from serialization to cache storage.
*/
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Cryptography\CryptographicUtilities;
use App\Framework\LiveComponents\Serialization\EncryptedStateSerializer;
use App\Framework\LiveComponents\Serialization\StateEncryptor;
use App\Framework\LiveComponents\Exceptions\StateEncryptionException;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\StateManagement\SerializableState;
// Test State with Sensitive Data
final readonly class PaymentState implements SerializableState
{
public function __construct(
public string $paymentId,
public string $cardNumber, // Sensitive - PCI data
public string $cvv, // Sensitive - must be encrypted
public float $amount,
public string $currency
) {}
public function toArray(): array
{
return [
'payment_id' => $this->paymentId,
'card_number' => $this->cardNumber,
'cvv' => $this->cvv,
'amount' => $this->amount,
'currency' => $this->currency,
];
}
public static function fromArray(array $data): self
{
return new self(
paymentId: $data['payment_id'],
cardNumber: $data['card_number'],
cvv: $data['cvv'],
amount: $data['amount'],
currency: $data['currency']
);
}
}
describe('State Encryption Integration', function () {
beforeEach(function () {
// Setup encryption infrastructure
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$this->encryptor = new StateEncryptor(
$this->encryptionKey,
$this->crypto,
$this->random
);
$this->serializer = new EncryptedStateSerializer($this->encryptor);
$this->cache = new InMemoryCache();
// Test payment state
$this->paymentState = new PaymentState(
paymentId: 'pay_12345',
cardNumber: '4111111111111111',
cvv: '123',
amount: 99.99,
currency: 'USD'
);
});
describe('End-to-End Encryption Flow', function () {
it('encrypts state and stores in cache', function () {
// 1. Encrypt state
$encrypted = $this->serializer->serialize($this->paymentState);
// 2. Store in cache
$cacheKey = CacheKey::fromString('payment:session:abc123');
$cacheItem = CacheItem::forSet(
key: $cacheKey,
value: $encrypted,
ttl: Duration::fromMinutes(15)
);
$this->cache->set($cacheItem);
// 3. Retrieve from cache
$retrieved = $this->cache->get($cacheKey);
expect($retrieved->isHit)->toBeTrue();
expect($retrieved->value)->toBeString();
// 4. Verify encrypted (no plaintext)
expect($retrieved->value)->not->toContain('4111111111111111');
expect($retrieved->value)->not->toContain('123');
});
it('decrypts state correctly from cache', function () {
// Encrypt and store
$encrypted = $this->serializer->serialize($this->paymentState);
$cacheKey = CacheKey::fromString('payment:session:xyz789');
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: $encrypted
));
// Retrieve and decrypt
$cached = $this->cache->get($cacheKey);
$decrypted = $this->serializer->deserialize(
$cached->value,
PaymentState::class
);
// Verify decryption
expect($decrypted)->toBeInstanceOf(PaymentState::class);
expect($decrypted->paymentId)->toBe('pay_12345');
expect($decrypted->cardNumber)->toBe('4111111111111111');
expect($decrypted->cvv)->toBe('123');
expect($decrypted->amount)->toBe(99.99);
});
it('handles cache expiration correctly', function () {
// Store with short TTL
$encrypted = $this->serializer->serialize($this->paymentState);
$cacheKey = CacheKey::fromString('payment:session:expired');
// Manually expire by not setting (or setting expired)
// InMemoryCache doesn't auto-expire, so we simulate by not storing
$retrieved = $this->cache->get($cacheKey);
expect($retrieved->isHit)->toBeFalse();
});
});
describe('Security Properties', function () {
it('prevents tampering with MAC verification', function () {
// Encrypt and store
$encrypted = $this->serializer->serialize($this->paymentState);
$cacheKey = CacheKey::fromString('payment:tamper-test');
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: $encrypted
));
// Retrieve and tamper
$cached = $this->cache->get($cacheKey);
$tampered = substr($cached->value, 0, -5) . 'XXXXX';
// Store tampered data
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: $tampered
));
// Attempt to decrypt should fail
$tamperedData = $this->cache->get($cacheKey);
expect(fn() => $this->serializer->deserialize(
$tamperedData->value,
PaymentState::class
))->toThrow(StateEncryptionException::class);
});
it('uses unique nonces for each encryption', function () {
// Encrypt same state twice
$encrypted1 = $this->serializer->serialize($this->paymentState);
$encrypted2 = $this->serializer->serialize($this->paymentState);
// Should be different due to unique nonces
expect($encrypted1)->not->toBe($encrypted2);
// Both should decrypt to same state
$decrypted1 = $this->serializer->deserialize($encrypted1, PaymentState::class);
$decrypted2 = $this->serializer->deserialize($encrypted2, PaymentState::class);
expect($decrypted1->cardNumber)->toBe($decrypted2->cardNumber);
});
it('does not leak sensitive data in cache storage', function () {
// Store multiple encrypted states
for ($i = 1; $i <= 5; $i++) {
$state = new PaymentState(
paymentId: "pay_{$i}",
cardNumber: '4111111111111111',
cvv: '123',
amount: 100.00 * $i,
currency: 'USD'
);
$encrypted = $this->serializer->serialize($state);
$cacheKey = CacheKey::fromString("payment:session:{$i}");
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: $encrypted
));
}
// Scan all cache entries
$allKeys = $this->cache->scan('payment:session:*');
foreach ($allKeys as $keyString) {
$cacheKey = CacheKey::fromString($keyString);
$item = $this->cache->get($cacheKey);
// No plaintext card data should be visible
expect($item->value)->not->toContain('4111111111111111');
expect($item->value)->not->toContain('123');
}
});
});
describe('Performance Characteristics', function () {
it('maintains acceptable encryption overhead', function () {
$startTime = microtime(true);
for ($i = 0; $i < 100; $i++) {
$encrypted = $this->serializer->serialize($this->paymentState);
$this->serializer->deserialize($encrypted, PaymentState::class);
}
$duration = (microtime(true) - $startTime) * 1000;
$avgTimeMs = $duration / 100;
// Should be < 2ms per encrypt+decrypt cycle (as per docs)
expect($avgTimeMs)->toBeLessThan(2.0);
});
it('handles concurrent cache operations', function () {
// Simulate concurrent writes
$operations = [];
for ($i = 0; $i < 10; $i++) {
$state = new PaymentState(
paymentId: "concurrent_{$i}",
cardNumber: '4111111111111111',
cvv: '123',
amount: 50.00,
currency: 'USD'
);
$encrypted = $this->serializer->serialize($state);
$cacheKey = CacheKey::fromString("payment:concurrent:{$i}");
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: $encrypted
));
$operations[] = ['key' => $cacheKey, 'paymentId' => "concurrent_{$i}"];
}
// Verify all operations succeeded
foreach ($operations as $op) {
$cached = $this->cache->get($op['key']);
$decrypted = $this->serializer->deserialize(
$cached->value,
PaymentState::class
);
expect($decrypted->paymentId)->toBe($op['paymentId']);
}
});
});
describe('Error Handling', function () {
it('throws StateEncryptionException on decryption failure', function () {
// Store corrupted data
$cacheKey = CacheKey::fromString('payment:corrupted');
$this->cache->set(CacheItem::forSet(
key: $cacheKey,
value: 'corrupted-encrypted-data'
));
$cached = $this->cache->get($cacheKey);
expect(fn() => $this->serializer->deserialize(
$cached->value,
PaymentState::class
))->toThrow(StateEncryptionException::class);
});
it('does not leak encryption key in error messages', function () {
try {
$this->serializer->deserialize(
'invalid-data',
PaymentState::class
);
} catch (StateEncryptionException $e) {
// Error message should not contain encryption key
expect($e->getMessage())->not->toContain(bin2hex($this->encryptionKey));
}
});
it('handles invalid state class gracefully', function () {
$encrypted = $this->serializer->serialize($this->paymentState);
expect(fn() => $this->serializer->deserialize(
$encrypted,
\stdClass::class
))->toThrow(StateEncryptionException::class);
});
});
describe('Multi-Component Isolation', function () {
it('maintains separate encrypted states per component', function () {
$component1Key = CacheKey::fromString('payment:component1');
$component2Key = CacheKey::fromString('payment:component2');
$state1 = new PaymentState(
paymentId: 'pay_comp1',
cardNumber: '4111111111111111',
cvv: '111',
amount: 50.00,
currency: 'USD'
);
$state2 = new PaymentState(
paymentId: 'pay_comp2',
cardNumber: '5555555555554444',
cvv: '222',
amount: 75.00,
currency: 'EUR'
);
// Encrypt and store both
$encrypted1 = $this->serializer->serialize($state1);
$encrypted2 = $this->serializer->serialize($state2);
$this->cache->set(CacheItem::forSet(key: $component1Key, value: $encrypted1));
$this->cache->set(CacheItem::forSet(key: $component2Key, value: $encrypted2));
// Verify different encrypted data
$cached1 = $this->cache->get($component1Key);
$cached2 = $this->cache->get($component2Key);
expect($cached1->value)->not->toBe($cached2->value);
// Decrypt and verify correct isolation
$decrypted1 = $this->serializer->deserialize($cached1->value, PaymentState::class);
$decrypted2 = $this->serializer->deserialize($cached2->value, PaymentState::class);
expect($decrypted1->paymentId)->toBe('pay_comp1');
expect($decrypted1->currency)->toBe('USD');
expect($decrypted2->paymentId)->toBe('pay_comp2');
expect($decrypted2->currency)->toBe('EUR');
});
});
});

View File

@@ -0,0 +1,467 @@
<?php
declare(strict_types=1);
/**
* State Encryption Security Integration Tests
*
* Tests the complete security stack integration:
* 1. CSRF Protection
* 2. Rate Limiting
* 3. Idempotency
* 4. State Encryption (NEW)
* 5. Authorization
*
* These tests ensure that all security layers work together correctly
* with encrypted state, especially for sensitive data scenarios.
*/
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\SmartCache;
use App\Framework\Cryptography\CryptographicUtilities;
use App\Framework\LiveComponents\Serialization\EncryptedStateSerializer;
use App\Framework\LiveComponents\Serialization\StateEncryptor;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\StateManagement\CacheBasedStateManager;
use App\Framework\StateManagement\Transformers\EncryptionTransformer;
use App\Framework\StateManagement\SerializableState;
// Test State with Sensitive Data
final readonly class SensitiveUserState implements SerializableState
{
public function __construct(
public string $userId,
public string $email,
public string $sessionToken, // Sensitive - should be encrypted
public array $privateData // Sensitive - should be encrypted
) {}
public function toArray(): array
{
return [
'user_id' => $this->userId,
'email' => $this->email,
'session_token' => $this->sessionToken,
'private_data' => $this->privateData,
];
}
public static function fromArray(array $data): self
{
return new self(
userId: $data['user_id'],
email: $data['email'],
sessionToken: $data['session_token'],
privateData: $data['private_data']
);
}
}
describe('State Encryption Integration with Security Layers', function () {
beforeEach(function () {
// Setup encryption infrastructure
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$this->encryptor = new StateEncryptor(
$this->encryptionKey,
$this->crypto,
$this->random
);
$this->serializer = new EncryptedStateSerializer($this->encryptor);
// Setup in-memory cache for testing (wrapped in SmartCache)
$cacheDriver = new InMemoryCache();
$this->cache = new SmartCache($cacheDriver);
// Test sensitive state
$this->sensitiveState = new SensitiveUserState(
userId: 'user-123',
email: 'user@example.com',
sessionToken: 'sensitive-token-abc123',
privateData: [
'ssn' => '123-45-6789',
'credit_card' => '4111-1111-1111-1111',
'api_key' => 'sk_live_secret123'
]
);
// Setup StateManager with encryption for SensitiveUserState
$encryptionTransformer = new EncryptionTransformer(
$this->serializer,
SensitiveUserState::class
);
$this->stateManager = (new CacheBasedStateManager(
cache: $this->cache,
keyPrefix: 'livecomponent_state',
stateClass: SensitiveUserState::class
))->addTransformer($encryptionTransformer);
});
describe('Encrypted State Storage', function () {
it('stores state encrypted in cache', function () {
$componentId = 'secure-component:test-1';
// Store sensitive state
$this->stateManager->store($componentId, $this->sensitiveState);
// Verify state is stored in cache
$cacheKey = "livecomponent_state:{$componentId}";
$cachedData = $this->cache->get($cacheKey);
expect($cachedData)->not->toBeNull();
// Cached data should be encrypted (base64 string)
expect($cachedData)->toBeString();
expect(base64_decode($cachedData, strict: true))->not->toBeFalse();
// Cached data should NOT contain plaintext sensitive info
expect($cachedData)->not->toContain('sensitive-token-abc123');
expect($cachedData)->not->toContain('123-45-6789');
expect($cachedData)->not->toContain('4111-1111-1111-1111');
expect($cachedData)->not->toContain('sk_live_secret123');
});
it('retrieves and decrypts state correctly', function () {
$componentId = 'secure-component:test-2';
// Store and retrieve
$this->stateManager->store($componentId, $this->sensitiveState);
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
// Should decrypt to original state
expect($retrieved)->toBeInstanceOf(SensitiveUserState::class);
expect($retrieved->userId)->toBe('user-123');
expect($retrieved->email)->toBe('user@example.com');
expect($retrieved->sessionToken)->toBe('sensitive-token-abc123');
expect($retrieved->privateData['ssn'])->toBe('123-45-6789');
});
it('prevents state tampering with MAC verification', function () {
$componentId = 'secure-component:test-3';
// Store encrypted state
$this->stateManager->store($componentId, $this->sensitiveState);
// Get encrypted data from cache
$cacheKey = "livecomponent_state:{$componentId}";
$encryptedData = $this->cache->get($cacheKey);
// Tamper with encrypted data (modify last 5 characters)
$tampered = substr($encryptedData, 0, -5) . 'XXXXX';
$this->cache->set($cacheKey, $tampered);
// Retrieval should fail due to MAC verification
expect(fn() => $this->stateManager->retrieve($componentId, SensitiveUserState::class))
->toThrow(\Exception::class);
});
});
describe('Encryption with CSRF Protection', function () {
it('encrypts state even when CSRF validation fails', function () {
$componentId = 'secure-component:csrf-test';
// Store state with encryption
$this->stateManager->store($componentId, $this->sensitiveState);
// Verify encrypted in cache (CSRF failure doesn't expose data)
$cacheKey = "livecomponent_state:{$componentId}";
$cachedData = $this->cache->get($cacheKey);
expect($cachedData)->not->toContain('sensitive-token-abc123');
expect($cachedData)->not->toContain('sk_live_secret123');
});
it('maintains encryption through CSRF token rotation', function () {
$componentId = 'secure-component:csrf-rotation';
// Store state
$this->stateManager->store($componentId, $this->sensitiveState);
// Simulate CSRF token rotation (state should remain encrypted)
$retrieved1 = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
// Re-store with potentially new CSRF token
$this->stateManager->store($componentId, $retrieved1);
// Should still decrypt correctly
$retrieved2 = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
expect($retrieved2->sessionToken)->toBe('sensitive-token-abc123');
});
});
describe('Encryption with Rate Limiting', function () {
it('maintains encrypted state during rate limit enforcement', function () {
$componentId = 'secure-component:rate-limit';
// Store sensitive state
$this->stateManager->store($componentId, $this->sensitiveState);
// Simulate multiple requests (rate limiting scenario)
for ($i = 0; $i < 5; $i++) {
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
// Each retrieval should decrypt correctly
expect($retrieved->sessionToken)->toBe('sensitive-token-abc123');
}
// State should remain encrypted in cache
$cacheKey = "livecomponent_state:{$componentId}";
$cachedData = $this->cache->get($cacheKey);
expect($cachedData)->not->toContain('sensitive-token-abc123');
});
it('does not leak sensitive data in rate limit errors', function () {
$componentId = 'secure-component:rate-limit-error';
// Store state
$this->stateManager->store($componentId, $this->sensitiveState);
// Rate limit should not expose encrypted state
$cacheKey = "livecomponent_state:{$componentId}";
$encryptedData = $this->cache->get($cacheKey);
// Error messages should not contain plaintext
expect($encryptedData)->not->toContain('sensitive-token-abc123');
expect($encryptedData)->not->toContain('api_key');
});
});
describe('Encryption with Idempotency', function () {
it('encrypts idempotent cached results', function () {
$componentId = 'secure-component:idempotency';
$idempotencyKey = 'idem-key-' . uniqid();
// Store state
$this->stateManager->store($componentId, $this->sensitiveState);
// First execution - cache result
$result1 = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
// Simulate idempotency caching
$idempotencyCacheKey = "idempotency:{$idempotencyKey}";
$this->cache->set($idempotencyCacheKey, $result1->toArray());
// Cached idempotent result should not expose sensitive data
$cachedResult = $this->cache->get($idempotencyCacheKey);
// Note: Idempotency cache stores unencrypted results
// This is intentional - idempotency is short-lived and action-specific
expect($cachedResult)->toBeArray();
});
it('retrieves encrypted state for idempotent requests', function () {
$componentId = 'secure-component:idempotent-retrieval';
$idempotencyKey = 'idem-' . uniqid();
// Store encrypted state
$this->stateManager->store($componentId, $this->sensitiveState);
// Multiple idempotent retrievals
for ($i = 0; $i < 3; $i++) {
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
expect($retrieved->sessionToken)->toBe('sensitive-token-abc123');
}
// State remains encrypted
$cacheKey = "livecomponent_state:{$componentId}";
$cachedData = $this->cache->get($cacheKey);
expect($cachedData)->not->toContain('sensitive-token-abc123');
});
});
describe('Complete Security Stack Integration', function () {
it('enforces all 5 security layers with encrypted state', function () {
$componentId = 'secure-component:full-stack';
// 1. Store with encryption (Layer 5)
$this->stateManager->store($componentId, $this->sensitiveState);
// Verify encryption
$cacheKey = "livecomponent_state:{$componentId}";
$encryptedData = $this->cache->get($cacheKey);
expect($encryptedData)->not->toContain('sensitive-token-abc123');
// 2. Retrieve (decrypts automatically via transformer)
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
// 3. Verify decryption (all sensitive data intact)
expect($retrieved->userId)->toBe('user-123');
expect($retrieved->sessionToken)->toBe('sensitive-token-abc123');
expect($retrieved->privateData['ssn'])->toBe('123-45-6789');
// 4. Update state (re-encrypts on store)
$updatedState = new SensitiveUserState(
userId: $retrieved->userId,
email: $retrieved->email,
sessionToken: 'new-token-xyz789',
privateData: $retrieved->privateData
);
$this->stateManager->store($componentId, $updatedState);
// 5. Verify new state is encrypted
$newEncryptedData = $this->cache->get($cacheKey);
expect($newEncryptedData)->not->toContain('new-token-xyz789');
expect($newEncryptedData)->not->toBe($encryptedData); // Different encryption
// 6. Final retrieval verification
$final = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
expect($final->sessionToken)->toBe('new-token-xyz789');
});
it('security validation order: CSRF -> Rate Limit -> Idempotency -> Authorization -> Encryption', function () {
$componentId = 'secure-component:validation-order';
// Layer 5: Encryption (happens on store/retrieve)
$this->stateManager->store($componentId, $this->sensitiveState);
// Verify encryption layer active
$cacheKey = "livecomponent_state:{$componentId}";
$encryptedData = $this->cache->get($cacheKey);
expect($encryptedData)->toBeString();
expect($encryptedData)->not->toContain('sensitive-token-abc123');
// Layers 1-4 would be validated before action execution
// Layer 5 (Encryption) protects state at rest regardless of validation results
// Even if validation fails, state remains encrypted
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
expect($retrieved->sessionToken)->toBe('sensitive-token-abc123');
});
});
describe('Performance with Encryption', function () {
it('maintains acceptable performance with encryption overhead', function () {
$componentId = 'secure-component:performance';
// Measure encryption overhead
$startTime = microtime(true);
for ($i = 0; $i < 100; $i++) {
$this->stateManager->store($componentId, $this->sensitiveState);
$this->stateManager->retrieve($componentId, SensitiveUserState::class);
}
$duration = microtime(true) - $startTime;
$avgTimeMs = ($duration / 100) * 1000;
// Encryption overhead should be < 2ms per operation (as per docs)
expect($avgTimeMs)->toBeLessThan(2.0);
});
it('handles large encrypted states efficiently', function () {
// Large private data
$largePrivateData = [];
for ($i = 0; $i < 100; $i++) {
$largePrivateData["key_{$i}"] = str_repeat('sensitive-data-', 10);
}
$largeState = new SensitiveUserState(
userId: 'user-large',
email: 'large@example.com',
sessionToken: 'large-token',
privateData: $largePrivateData
);
$componentId = 'secure-component:large-state';
$startTime = microtime(true);
$this->stateManager->store($componentId, $largeState);
$retrieved = $this->stateManager->retrieve($componentId, SensitiveUserState::class);
$duration = (microtime(true) - $startTime) * 1000;
// Should handle large state in < 5ms
expect($duration)->toBeLessThan(5.0);
expect(count($retrieved->privateData))->toBe(100);
});
});
describe('Error Handling with Encryption', function () {
it('throws StateEncryptionException on decryption failure', function () {
$componentId = 'secure-component:decrypt-error';
// Store valid encrypted state
$this->stateManager->store($componentId, $this->sensitiveState);
// Corrupt the encrypted data
$cacheKey = "livecomponent_state:{$componentId}";
$this->cache->set($cacheKey, 'corrupted-data');
// Should throw StateEncryptionException
expect(fn() => $this->stateManager->retrieve($componentId, SensitiveUserState::class))
->toThrow(\Exception::class);
});
it('does not leak sensitive data in error messages', function () {
$componentId = 'secure-component:error-leak';
try {
// Attempt to retrieve non-existent encrypted state
$this->stateManager->retrieve($componentId, SensitiveUserState::class);
} catch (\Exception $e) {
// Error message should not contain any encryption key info
expect($e->getMessage())->not->toContain(bin2hex($this->encryptionKey));
}
});
it('maintains encryption even when state retrieval fails', function () {
$componentId = 'secure-component:retrieval-fail';
// Store valid state
$this->stateManager->store($componentId, $this->sensitiveState);
// Clear cache to simulate retrieval failure
$this->cache->clear();
// Re-store (should encrypt again)
$this->stateManager->store($componentId, $this->sensitiveState);
// Verify encryption
$cacheKey = "livecomponent_state:{$componentId}";
$encryptedData = $this->cache->get($cacheKey);
expect($encryptedData)->not->toContain('sensitive-token-abc123');
});
});
describe('Multi-Component Encryption Isolation', function () {
it('encrypts each component state independently', function () {
$componentId1 = 'secure-component:isolation-1';
$componentId2 = 'secure-component:isolation-2';
$state1 = new SensitiveUserState(
userId: 'user-1',
email: 'user1@example.com',
sessionToken: 'token-1',
privateData: ['key' => 'value1']
);
$state2 = new SensitiveUserState(
userId: 'user-2',
email: 'user2@example.com',
sessionToken: 'token-2',
privateData: ['key' => 'value2']
);
// Store both
$this->stateManager->store($componentId1, $state1);
$this->stateManager->store($componentId2, $state2);
// Each should have different encrypted data (unique nonces)
$encrypted1 = $this->cache->get("livecomponent_state:{$componentId1}");
$encrypted2 = $this->cache->get("livecomponent_state:{$componentId2}");
expect($encrypted1)->not->toBe($encrypted2);
// Each should decrypt to correct state
$retrieved1 = $this->stateManager->retrieve($componentId1, SensitiveUserState::class);
$retrieved2 = $this->stateManager->retrieve($componentId2, SensitiveUserState::class);
expect($retrieved1->userId)->toBe('user-1');
expect($retrieved2->userId)->toBe('user-2');
});
});
});