fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
@@ -10,12 +10,12 @@ use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\Method as HttpMethod;
|
||||
use App\Framework\Http\Url\Url;
|
||||
use App\Framework\Http\Url\WhatwgUrl;
|
||||
use App\Framework\HttpClient\AuthConfig;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\HttpClient\Status;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
|
||||
describe('ApiGateway', function () {
|
||||
@@ -51,7 +51,7 @@ describe('ApiGateway', function () {
|
||||
$mockCache = new class implements \App\Framework\Cache\Cache {
|
||||
public function get(\App\Framework\Cache\CacheIdentifier ...$identifiers): \App\Framework\Cache\CacheResult
|
||||
{
|
||||
return new \App\Framework\Cache\CacheResult(hits: [], misses: $identifiers);
|
||||
return \App\Framework\Cache\CacheResult::empty();
|
||||
}
|
||||
public function set(\App\Framework\Cache\CacheItem ...$items): bool { return true; }
|
||||
public function has(\App\Framework\Cache\CacheIdentifier ...$identifiers): array { return []; }
|
||||
@@ -89,39 +89,14 @@ describe('ApiGateway', function () {
|
||||
|
||||
$this->metrics = new \App\Framework\ApiGateway\Metrics\ApiMetrics();
|
||||
|
||||
$this->operationTracker = new class implements \App\Framework\Performance\OperationTracker {
|
||||
public function startOperation(
|
||||
string $operationId,
|
||||
\App\Framework\Performance\PerformanceCategory $category,
|
||||
array $contextData = []
|
||||
): \App\Framework\Performance\PerformanceSnapshot {
|
||||
return new \App\Framework\Performance\PerformanceSnapshot(
|
||||
operationId: $operationId,
|
||||
category: $category,
|
||||
startTime: microtime(true),
|
||||
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
|
||||
memoryUsed: 1024,
|
||||
peakMemory: 2048,
|
||||
contextData: $contextData
|
||||
);
|
||||
}
|
||||
$memoryMonitor = new \App\Framework\Performance\MemoryMonitor();
|
||||
|
||||
public function completeOperation(string $operationId): ?\App\Framework\Performance\PerformanceSnapshot {
|
||||
return new \App\Framework\Performance\PerformanceSnapshot(
|
||||
operationId: $operationId,
|
||||
category: \App\Framework\Performance\PerformanceCategory::HTTP,
|
||||
startTime: microtime(true) - 0.01,
|
||||
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
|
||||
memoryUsed: 1024,
|
||||
peakMemory: 2048,
|
||||
contextData: []
|
||||
);
|
||||
}
|
||||
|
||||
public function failOperation(string $operationId, \Throwable $exception): ?\App\Framework\Performance\PerformanceSnapshot {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$this->operationTracker = new \App\Framework\Performance\OperationTracker(
|
||||
clock: $mockClock,
|
||||
memoryMonitor: $memoryMonitor,
|
||||
logger: null,
|
||||
eventDispatcher: null
|
||||
);
|
||||
|
||||
$this->apiGateway = new ApiGateway(
|
||||
$this->httpClient,
|
||||
@@ -136,7 +111,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -174,14 +149,14 @@ describe('ApiGateway', function () {
|
||||
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->options->auth)->not->toBeNull();
|
||||
expect($this->capturedRequest->options->auth->type)->toBe('basic');
|
||||
expect($this->capturedRequest->options->auth->type->value)->toBe('basic');
|
||||
});
|
||||
|
||||
it('applies custom header authentication when ApiRequest uses AuthConfig::custom()', function () {
|
||||
$request = new class implements ApiRequest, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -221,14 +196,14 @@ describe('ApiGateway', function () {
|
||||
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->options->auth)->not->toBeNull();
|
||||
expect($this->capturedRequest->options->auth->type)->toBe('custom');
|
||||
expect($this->capturedRequest->options->auth->type->value)->toBe('custom');
|
||||
});
|
||||
|
||||
it('does not apply authentication when ApiRequest does not implement HasAuth', function () {
|
||||
$request = new class implements ApiRequest {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -270,7 +245,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest, HasPayload, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -329,7 +304,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -376,7 +351,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -414,7 +389,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -445,14 +420,14 @@ describe('ApiGateway', function () {
|
||||
|
||||
$response = $this->apiGateway->send($request);
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->method)->toBe('GET');
|
||||
expect($this->capturedRequest->method->value)->toBe('GET');
|
||||
});
|
||||
|
||||
it('supports POST requests with payload', function () {
|
||||
$request = new class implements ApiRequest, HasPayload {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -488,7 +463,7 @@ describe('ApiGateway', function () {
|
||||
|
||||
$response = $this->apiGateway->send($request);
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->method)->toBe('POST');
|
||||
expect($this->capturedRequest->method->value)->toBe('POST');
|
||||
$bodyData = json_decode($this->capturedRequest->body, true);
|
||||
expect($bodyData)->toBe(['data' => 'test']);
|
||||
});
|
||||
@@ -497,7 +472,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test/123'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -533,14 +508,14 @@ describe('ApiGateway', function () {
|
||||
|
||||
$response = $this->apiGateway->send($request);
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->method)->toBe('DELETE');
|
||||
expect($this->capturedRequest->method->value)->toBe('DELETE');
|
||||
});
|
||||
|
||||
it('supports PATCH requests with payload', function () {
|
||||
$request = new class implements ApiRequest, HasPayload, HasAuth {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test/123'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
@@ -581,7 +556,7 @@ describe('ApiGateway', function () {
|
||||
|
||||
$response = $this->apiGateway->send($request);
|
||||
expect($response->status)->toBe(Status::OK);
|
||||
expect($this->capturedRequest->method)->toBe('PATCH');
|
||||
expect($this->capturedRequest->method->value)->toBe('PATCH');
|
||||
$bodyData = json_decode($this->capturedRequest->body, true);
|
||||
expect($bodyData)->toBe(['name' => 'Updated']);
|
||||
});
|
||||
@@ -592,7 +567,7 @@ describe('ApiGateway', function () {
|
||||
$request = new class implements ApiRequest {
|
||||
public function getEndpoint(): ApiEndpoint
|
||||
{
|
||||
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
||||
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
|
||||
}
|
||||
|
||||
public function getMethod(): HttpMethod
|
||||
|
||||
@@ -20,8 +20,8 @@ use App\Framework\DI\Container;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Discovery\InitializerProcessor;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\Reflection\ReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\ReflectionProvider;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Discovery\Cache\DiscoveryCacheIdentifiers;
|
||||
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
|
||||
use App\Framework\Discovery\InitializerProcessor;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\Reflection\ReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\ReflectionProvider;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
|
||||
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
|
||||
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
|
||||
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
|
||||
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
|
||||
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('Dynamic Route Parameter Processing Debug', function () {
|
||||
$discoveryVisitor->onScanStart();
|
||||
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
|
||||
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
|
||||
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
|
||||
|
||||
$discoveryVisitor->visitClass($className, $filePath, $reflection);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
use App\Framework\DI\ContainerCompiler;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\DI\DependencyResolver;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tempDir = sys_get_temp_dir() . '/container-compiler-test-' . uniqid();
|
||||
|
||||
@@ -10,8 +10,8 @@ use App\Framework\Core\PathProvider;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\Reflection\ReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\ReflectionProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,7 +92,7 @@ final class SimpleMemoryTest extends TestCase
|
||||
$clock = new SystemClock();
|
||||
|
||||
// Create reflection provider without dependencies
|
||||
$reflectionProvider = new \App\Framework\Reflection\CachedReflectionProvider();
|
||||
$reflectionProvider = new \App\Framework\ReflectionLegacy\CachedReflectionProvider();
|
||||
|
||||
$config = new \App\Framework\Discovery\ValueObjects\DiscoveryConfiguration(
|
||||
paths: ['/var/www/html/src'],
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\UnifiedDiscoveryService;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Tests\Framework\Http;
|
||||
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\MiddlewareDependencyResolver;
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Framework\Http\MiddlewareDependencyResolver;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Reflection\ReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\ReflectionProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MiddlewareDependencyResolverTest extends TestCase
|
||||
|
||||
417
tests/Framework/MagicLinks/MagicLinksValueObjectsTest.php
Normal file
417
tests/Framework/MagicLinks/MagicLinksValueObjectsTest.php
Normal file
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\MagicLinks\ValueObjects\{
|
||||
TokenAction,
|
||||
MagicLinkToken,
|
||||
TokenConfig,
|
||||
ActionResult,
|
||||
MagicLinkData
|
||||
};
|
||||
|
||||
describe('TokenAction', function () {
|
||||
it('creates valid token action', function () {
|
||||
$action = new TokenAction('email_verification');
|
||||
|
||||
expect($action->value)->toBe('email_verification');
|
||||
});
|
||||
|
||||
it('validates action format', function () {
|
||||
new TokenAction('invalid-action'); // Hyphens not allowed
|
||||
})->throws(InvalidArgumentException::class, 'must contain only lowercase letters and underscores');
|
||||
|
||||
it('rejects empty action', function () {
|
||||
new TokenAction('');
|
||||
})->throws(InvalidArgumentException::class, 'cannot be empty');
|
||||
|
||||
it('compares actions correctly', function () {
|
||||
$action1 = new TokenAction('email_verification');
|
||||
$action2 = new TokenAction('email_verification');
|
||||
$action3 = new TokenAction('password_reset');
|
||||
|
||||
expect($action1->equals($action2))->toBeTrue();
|
||||
expect($action1->equals($action3))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MagicLinkToken', function () {
|
||||
it('creates valid token', function () {
|
||||
$token = new MagicLinkToken('abcdef0123456789'); // 16 chars
|
||||
|
||||
expect($token->value)->toBe('abcdef0123456789');
|
||||
});
|
||||
|
||||
it('rejects short tokens', function () {
|
||||
new MagicLinkToken('short'); // Less than 16 chars
|
||||
})->throws(InvalidArgumentException::class, 'must be at least 16 characters');
|
||||
|
||||
it('generates cryptographically secure tokens', function () {
|
||||
$token1 = MagicLinkToken::generate();
|
||||
$token2 = MagicLinkToken::generate();
|
||||
|
||||
expect($token1->value)->not->toBe($token2->value);
|
||||
expect(strlen($token1->value))->toBe(64); // 32 bytes * 2 (hex)
|
||||
});
|
||||
|
||||
it('compares tokens with constant time', function () {
|
||||
$token1 = new MagicLinkToken('abcdef0123456789');
|
||||
$token2 = new MagicLinkToken('abcdef0123456789');
|
||||
$token3 = new MagicLinkToken('different0123456');
|
||||
|
||||
expect($token1->equals($token2))->toBeTrue();
|
||||
expect($token1->equals($token3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates tokens with custom length', function () {
|
||||
$token = MagicLinkToken::generate(16); // 16 bytes
|
||||
|
||||
expect(strlen($token->value))->toBe(32); // 16 bytes * 2 (hex)
|
||||
});
|
||||
});
|
||||
|
||||
describe('TokenConfig', function () {
|
||||
it('creates default config', function () {
|
||||
$config = new TokenConfig();
|
||||
|
||||
expect($config->ttlSeconds)->toBe(3600);
|
||||
expect($config->oneTimeUse)->toBeFalse();
|
||||
expect($config->maxUses)->toBeNull();
|
||||
expect($config->ipRestriction)->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates custom config', function () {
|
||||
$config = new TokenConfig(
|
||||
ttlSeconds: 7200,
|
||||
oneTimeUse: true,
|
||||
maxUses: null,
|
||||
ipRestriction: true
|
||||
);
|
||||
|
||||
expect($config->ttlSeconds)->toBe(7200);
|
||||
expect($config->oneTimeUse)->toBeTrue();
|
||||
expect($config->ipRestriction)->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates positive ttl', function () {
|
||||
new TokenConfig(ttlSeconds: 0);
|
||||
})->throws(InvalidArgumentException::class, 'TTL must be positive');
|
||||
|
||||
it('validates positive max uses', function () {
|
||||
new TokenConfig(maxUses: 0);
|
||||
})->throws(InvalidArgumentException::class, 'Max uses must be positive');
|
||||
|
||||
it('validates one-time-use consistency', function () {
|
||||
new TokenConfig(oneTimeUse: true, maxUses: 5);
|
||||
})->throws(InvalidArgumentException::class, 'One-time-use tokens cannot have maxUses');
|
||||
|
||||
it('creates email verification config', function () {
|
||||
$config = TokenConfig::forEmailVerification();
|
||||
|
||||
expect($config->ttlSeconds)->toBe(86400); // 24 hours
|
||||
expect($config->oneTimeUse)->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates password reset config', function () {
|
||||
$config = TokenConfig::forPasswordReset();
|
||||
|
||||
expect($config->ttlSeconds)->toBe(3600); // 1 hour
|
||||
expect($config->oneTimeUse)->toBeTrue();
|
||||
expect($config->ipRestriction)->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates document access config', function () {
|
||||
$config = TokenConfig::forDocumentAccess(3);
|
||||
|
||||
expect($config->ttlSeconds)->toBe(3600);
|
||||
expect($config->maxUses)->toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionResult', function () {
|
||||
it('creates successful result', function () {
|
||||
$result = new ActionResult(
|
||||
success: true,
|
||||
message: 'Email verified',
|
||||
data: ['user_id' => 123]
|
||||
);
|
||||
|
||||
expect($result->isSuccess())->toBeTrue();
|
||||
expect($result->message)->toBe('Email verified');
|
||||
expect($result->data)->toBe(['user_id' => 123]);
|
||||
});
|
||||
|
||||
it('creates failure result', function () {
|
||||
$result = new ActionResult(
|
||||
success: false,
|
||||
message: 'Verification failed',
|
||||
errors: ['Token expired']
|
||||
);
|
||||
|
||||
expect($result->isSuccess())->toBeFalse();
|
||||
expect($result->hasErrors())->toBeTrue();
|
||||
expect($result->errors)->toBe(['Token expired']);
|
||||
});
|
||||
|
||||
it('detects redirect presence', function () {
|
||||
$result1 = new ActionResult(
|
||||
success: true,
|
||||
message: 'Success',
|
||||
redirectUrl: '/dashboard'
|
||||
);
|
||||
|
||||
$result2 = new ActionResult(
|
||||
success: true,
|
||||
message: 'Success'
|
||||
);
|
||||
|
||||
expect($result1->hasRedirect())->toBeTrue();
|
||||
expect($result2->hasRedirect())->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates success via factory method', function () {
|
||||
$result = ActionResult::success(
|
||||
message: 'Operation completed',
|
||||
data: ['id' => 456],
|
||||
redirectUrl: '/success'
|
||||
);
|
||||
|
||||
expect($result->isSuccess())->toBeTrue();
|
||||
expect($result->message)->toBe('Operation completed');
|
||||
expect($result->data)->toBe(['id' => 456]);
|
||||
expect($result->redirectUrl)->toBe('/success');
|
||||
});
|
||||
|
||||
it('creates failure via factory method', function () {
|
||||
$result = ActionResult::failure(
|
||||
message: 'Operation failed',
|
||||
errors: ['Invalid input', 'Permission denied']
|
||||
);
|
||||
|
||||
expect($result->isSuccess())->toBeFalse();
|
||||
expect($result->errors)->toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MagicLinkData', function () {
|
||||
it('creates valid magic link data', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$expiresAt = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('email_verification'),
|
||||
payload: ['user_id' => 1, 'email' => 'test@example.com'],
|
||||
expiresAt: $expiresAt,
|
||||
createdAt: $now
|
||||
);
|
||||
|
||||
expect($data->id)->toBe('test-123');
|
||||
expect($data->action->value)->toBe('email_verification');
|
||||
expect($data->payload)->toBe(['user_id' => 1, 'email' => 'test@example.com']);
|
||||
});
|
||||
|
||||
it('detects expired tokens', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$past = $now->modify('-1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $past,
|
||||
createdAt: $now->modify('-2 hours')
|
||||
);
|
||||
|
||||
expect($data->isExpired())->toBeTrue();
|
||||
expect($data->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('validates non-expired tokens', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now
|
||||
);
|
||||
|
||||
expect($data->isExpired())->toBeFalse();
|
||||
expect($data->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('invalidates one-time-use tokens after use', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
oneTimeUse: true,
|
||||
isUsed: true
|
||||
);
|
||||
|
||||
expect($data->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('tracks use count correctly', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
useCount: 2,
|
||||
maxUses: 5
|
||||
);
|
||||
|
||||
expect($data->hasRemainingUses())->toBeTrue();
|
||||
expect($data->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('invalidates tokens exceeding max uses', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
useCount: 5,
|
||||
maxUses: 5
|
||||
);
|
||||
|
||||
expect($data->hasRemainingUses())->toBeFalse();
|
||||
expect($data->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('calculates remaining time correctly', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+3600 seconds');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now
|
||||
);
|
||||
|
||||
$remaining = $data->getSecondsUntilExpiry();
|
||||
expect($remaining)->toBeGreaterThan(3590);
|
||||
expect($remaining)->toBeLessThanOrEqual(3600);
|
||||
});
|
||||
|
||||
it('returns zero for expired token remaining time', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$past = $now->modify('-1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $past,
|
||||
createdAt: $now->modify('-2 hours')
|
||||
);
|
||||
|
||||
expect($data->getSecondsUntilExpiry())->toBe(0);
|
||||
});
|
||||
|
||||
it('marks token as used immutably', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
oneTimeUse: true
|
||||
);
|
||||
|
||||
$usedData = $data->withUsed(new DateTimeImmutable());
|
||||
|
||||
expect($data->isUsed)->toBeFalse(); // Original unchanged
|
||||
expect($usedData->isUsed)->toBeTrue();
|
||||
expect($usedData->useCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('increments use count immutably', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('test'),
|
||||
payload: [],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
useCount: 2
|
||||
);
|
||||
|
||||
$incremented = $data->withIncrementedUseCount();
|
||||
|
||||
expect($data->useCount)->toBe(2); // Original unchanged
|
||||
expect($incremented->useCount)->toBe(3);
|
||||
});
|
||||
|
||||
it('serializes to array correctly', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$future = $now->modify('+1 hour');
|
||||
|
||||
$data = new MagicLinkData(
|
||||
id: 'test-123',
|
||||
action: new TokenAction('email_verification'),
|
||||
payload: ['user_id' => 1],
|
||||
expiresAt: $future,
|
||||
createdAt: $now,
|
||||
oneTimeUse: true,
|
||||
createdByIp: '127.0.0.1',
|
||||
userAgent: 'Mozilla/5.0'
|
||||
);
|
||||
|
||||
$array = $data->toArray();
|
||||
|
||||
expect($array)->toHaveKey('id');
|
||||
expect($array)->toHaveKey('action');
|
||||
expect($array)->toHaveKey('payload');
|
||||
expect($array)->toHaveKey('is_valid');
|
||||
expect($array['action'])->toBe('email_verification');
|
||||
expect($array['payload'])->toBe(['user_id' => 1]);
|
||||
});
|
||||
|
||||
it('deserializes from array correctly', function () {
|
||||
$array = [
|
||||
'id' => 'test-123',
|
||||
'action' => 'password_reset',
|
||||
'payload' => ['user_id' => 456],
|
||||
'expires_at' => '2025-12-31 23:59:59',
|
||||
'created_at' => '2025-12-31 12:00:00',
|
||||
'one_time_use' => true,
|
||||
'created_by_ip' => '192.168.1.1',
|
||||
'user_agent' => 'Test Agent',
|
||||
'is_used' => false,
|
||||
'use_count' => 0
|
||||
];
|
||||
|
||||
$data = MagicLinkData::fromArray($array);
|
||||
|
||||
expect($data->id)->toBe('test-123');
|
||||
expect($data->action->value)->toBe('password_reset');
|
||||
expect($data->payload)->toBe(['user_id' => 456]);
|
||||
expect($data->oneTimeUse)->toBeTrue();
|
||||
expect($data->createdByIp)->toBe('192.168.1.1');
|
||||
});
|
||||
});
|
||||
338
tests/Framework/Mfa/MfaServiceTest.php
Normal file
338
tests/Framework/Mfa/MfaServiceTest.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Mfa\MfaService;
|
||||
use App\Framework\Mfa\MfaSecretFactory;
|
||||
use App\Framework\Mfa\Providers\TotpProvider;
|
||||
use App\Framework\Mfa\ValueObjects\MfaChallenge;
|
||||
use App\Framework\Mfa\ValueObjects\MfaCode;
|
||||
use App\Framework\Mfa\ValueObjects\MfaMethod;
|
||||
use App\Framework\Mfa\ValueObjects\MfaSecret;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->randomGenerator = new SecureRandomGenerator();
|
||||
$this->secretFactory = new MfaSecretFactory($this->randomGenerator);
|
||||
$this->totpProvider = new TotpProvider(timeStep: 30, digits: 6, windowSize: 1);
|
||||
$this->mfaService = new MfaService($this->totpProvider);
|
||||
});
|
||||
|
||||
describe('MfaSecretFactory', function () {
|
||||
it('generates valid base32 secret', function () {
|
||||
$secret = $this->secretFactory->generate();
|
||||
|
||||
expect($secret)->toBeInstanceOf(MfaSecret::class);
|
||||
expect($secret->value)->toMatch('/^[A-Z2-7]+$/');
|
||||
expect(strlen($secret->value))->toBeGreaterThanOrEqual(32);
|
||||
});
|
||||
|
||||
it('generates secret with custom length', function () {
|
||||
$secret = $this->secretFactory->generateWithLength(32);
|
||||
|
||||
// 32 bytes = 51-52 base32 characters (due to padding)
|
||||
expect(strlen($secret->value))->toBeGreaterThanOrEqual(51);
|
||||
});
|
||||
|
||||
it('throws exception for insufficient byte length', function () {
|
||||
$this->secretFactory->generateWithLength(10);
|
||||
})->throws(InvalidArgumentException::class, 'at least 16 bytes');
|
||||
});
|
||||
|
||||
describe('MfaSecret', function () {
|
||||
it('validates base32 format', function () {
|
||||
$validSecret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; // Valid base32 (32+ chars)
|
||||
$secret = MfaSecret::fromString($validSecret);
|
||||
|
||||
expect($secret->value)->toBe($validSecret);
|
||||
});
|
||||
|
||||
it('throws exception for invalid base32 characters', function () {
|
||||
MfaSecret::fromString('INVALID189'); // 1, 8, 9 not valid in base32
|
||||
})->throws(InvalidArgumentException::class, 'base32 encoded');
|
||||
|
||||
it('throws exception for too short secret', function () {
|
||||
MfaSecret::fromString('JBSWY3DP'); // Only 8 characters
|
||||
})->throws(InvalidArgumentException::class, 'at least 32 characters');
|
||||
|
||||
it('generates QR code URI', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$uri = $secret->toQrCodeUri('MyApp', 'user@example.com');
|
||||
|
||||
expect($uri)->toStartWith('otpauth://totp/');
|
||||
expect($uri)->toContain('MyApp:user%40example.com'); // URL-encoded email
|
||||
expect($uri)->toContain('secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
expect($uri)->toContain('issuer=MyApp');
|
||||
});
|
||||
|
||||
it('masks secret for safe logging', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$masked = $secret->getMasked();
|
||||
|
||||
expect($masked)->toBe('JBSW****3PXP');
|
||||
expect($masked)->not->toContain('EHPK3PXPJBSWY3DPEHPK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MfaChallenge', function () {
|
||||
it('creates valid challenge', function () {
|
||||
$challenge = MfaChallenge::create(
|
||||
challengeId: 'test-123',
|
||||
method: MfaMethod::TOTP,
|
||||
validitySeconds: 300,
|
||||
maxAttempts: 3
|
||||
);
|
||||
|
||||
expect($challenge->challengeId)->toBe('test-123');
|
||||
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
||||
expect($challenge->attempts)->toBe(0);
|
||||
expect($challenge->maxAttempts)->toBe(3);
|
||||
expect($challenge->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects expiration', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$created = $now->modify('-10 seconds');
|
||||
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
|
||||
|
||||
$challenge = new MfaChallenge(
|
||||
challengeId: 'test-123',
|
||||
method: MfaMethod::TOTP,
|
||||
createdAt: $created,
|
||||
expiresAt: $expired,
|
||||
attempts: 0,
|
||||
maxAttempts: 3
|
||||
);
|
||||
|
||||
expect($challenge->isExpired())->toBeTrue();
|
||||
expect($challenge->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('tracks attempts correctly', function () {
|
||||
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, maxAttempts: 3);
|
||||
|
||||
expect($challenge->attempts)->toBe(0);
|
||||
expect($challenge->hasAttemptsRemaining())->toBeTrue();
|
||||
expect($challenge->getRemainingAttempts())->toBe(3);
|
||||
|
||||
$challenge = $challenge->withIncrementedAttempts();
|
||||
expect($challenge->attempts)->toBe(1);
|
||||
expect($challenge->getRemainingAttempts())->toBe(2);
|
||||
|
||||
$challenge = $challenge->withIncrementedAttempts();
|
||||
$challenge = $challenge->withIncrementedAttempts();
|
||||
expect($challenge->attempts)->toBe(3);
|
||||
expect($challenge->hasAttemptsRemaining())->toBeFalse();
|
||||
expect($challenge->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('exports to array correctly', function () {
|
||||
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, validitySeconds: 300);
|
||||
$array = $challenge->toArray();
|
||||
|
||||
expect($array)->toHaveKey('challenge_id');
|
||||
expect($array)->toHaveKey('method');
|
||||
expect($array)->toHaveKey('method_display');
|
||||
expect($array)->toHaveKey('remaining_attempts');
|
||||
expect($array)->toHaveKey('seconds_until_expiry');
|
||||
expect($array)->toHaveKey('is_valid');
|
||||
|
||||
expect($array['challenge_id'])->toBe('test-123');
|
||||
expect($array['method'])->toBe('totp');
|
||||
expect($array['is_valid'])->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TotpProvider', function () {
|
||||
it('generates challenge for TOTP', function () {
|
||||
$challenge = $this->totpProvider->generateChallenge();
|
||||
|
||||
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
|
||||
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
||||
expect($challenge->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies valid TOTP code', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$challenge = $this->totpProvider->generateChallenge();
|
||||
|
||||
// Generate code for current time window
|
||||
$currentTime = time();
|
||||
$timeStep = (int) floor($currentTime / 30);
|
||||
|
||||
// We'll use the provider's internal logic to generate a valid code
|
||||
// In real usage, the authenticator app would generate this
|
||||
$reflector = new ReflectionClass($this->totpProvider);
|
||||
$method = $reflector->getMethod('generateCodeForTimeStep');
|
||||
$method->setAccessible(true);
|
||||
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
|
||||
|
||||
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
||||
|
||||
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects invalid TOTP code', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$challenge = $this->totpProvider->generateChallenge();
|
||||
$wrongCode = MfaCode::fromString('000000');
|
||||
|
||||
$result = $this->totpProvider->verify($challenge, $wrongCode, ['secret' => $secret]);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts code within time window', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$challenge = $this->totpProvider->generateChallenge();
|
||||
|
||||
$currentTime = time();
|
||||
$previousTimeStep = (int) floor($currentTime / 30) - 1; // One window before
|
||||
|
||||
$reflector = new ReflectionClass($this->totpProvider);
|
||||
$method = $reflector->getMethod('generateCodeForTimeStep');
|
||||
$method->setAccessible(true);
|
||||
$expectedCode = $method->invoke($this->totpProvider, $secret, $previousTimeStep);
|
||||
|
||||
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
||||
|
||||
// Should accept code from previous window (windowSize = 1)
|
||||
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception when secret is missing', function () {
|
||||
$challenge = $this->totpProvider->generateChallenge();
|
||||
$code = MfaCode::fromString('123456');
|
||||
|
||||
$this->totpProvider->verify($challenge, $code, []);
|
||||
})->throws(InvalidArgumentException::class, 'requires secret');
|
||||
|
||||
it('indicates TOTP does not require server-side code generation', function () {
|
||||
expect($this->totpProvider->requiresCodeGeneration())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns correct code validity period', function () {
|
||||
expect($this->totpProvider->getCodeValiditySeconds())->toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MfaService', function () {
|
||||
it('generates challenge via service', function () {
|
||||
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
|
||||
|
||||
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
|
||||
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
||||
});
|
||||
|
||||
it('verifies code via service', function () {
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
|
||||
|
||||
$currentTime = time();
|
||||
$timeStep = (int) floor($currentTime / 30);
|
||||
|
||||
$reflector = new ReflectionClass($this->totpProvider);
|
||||
$method = $reflector->getMethod('generateCodeForTimeStep');
|
||||
$method->setAccessible(true);
|
||||
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
|
||||
|
||||
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
||||
|
||||
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects verification for expired challenge', function () {
|
||||
$now = new DateTimeImmutable();
|
||||
$created = $now->modify('-10 seconds');
|
||||
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
|
||||
|
||||
$challenge = new MfaChallenge(
|
||||
challengeId: 'test-123',
|
||||
method: MfaMethod::TOTP,
|
||||
createdAt: $created,
|
||||
expiresAt: $expired,
|
||||
attempts: 0,
|
||||
maxAttempts: 3
|
||||
);
|
||||
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
$code = MfaCode::fromString('123456');
|
||||
|
||||
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks if method is supported', function () {
|
||||
expect($this->mfaService->supportsMethod(MfaMethod::TOTP))->toBeTrue();
|
||||
expect($this->mfaService->supportsMethod(MfaMethod::SMS))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns supported methods', function () {
|
||||
$methods = $this->mfaService->getSupportedMethods();
|
||||
|
||||
expect($methods)->toHaveCount(1);
|
||||
expect($methods[0])->toBe(MfaMethod::TOTP);
|
||||
});
|
||||
|
||||
it('throws exception for unsupported method', function () {
|
||||
$this->mfaService->generateChallenge(MfaMethod::SMS);
|
||||
})->throws(App\Framework\Mfa\Exceptions\MfaException::class, 'No MFA provider registered');
|
||||
});
|
||||
|
||||
describe('TOTP RFC 6238 Compliance', function () {
|
||||
it('generates 6-digit codes by default', function () {
|
||||
$provider = new TotpProvider();
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
|
||||
$reflector = new ReflectionClass($provider);
|
||||
$method = $reflector->getMethod('generateCodeForTimeStep');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$code = $method->invoke($provider, $secret, 12345);
|
||||
$codeStr = str_pad((string) $code, 6, '0', STR_PAD_LEFT);
|
||||
|
||||
expect(strlen($codeStr))->toBe(6);
|
||||
expect($codeStr)->toMatch('/^\d{6}$/');
|
||||
});
|
||||
|
||||
it('supports 8-digit codes', function () {
|
||||
$provider = new TotpProvider(digits: 8);
|
||||
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
||||
|
||||
$reflector = new ReflectionClass($provider);
|
||||
$method = $reflector->getMethod('generateCodeForTimeStep');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$code = $method->invoke($provider, $secret, 12345);
|
||||
$codeStr = str_pad((string) $code, 8, '0', STR_PAD_LEFT);
|
||||
|
||||
expect(strlen($codeStr))->toBe(8);
|
||||
});
|
||||
|
||||
it('uses 30-second time step by default', function () {
|
||||
$provider = new TotpProvider();
|
||||
|
||||
expect($provider->getCodeValiditySeconds())->toBe(30);
|
||||
});
|
||||
|
||||
it('validates constructor parameters', function () {
|
||||
expect(fn() => new TotpProvider(timeStep: 0))
|
||||
->toThrow(InvalidArgumentException::class, 'at least 1 second');
|
||||
|
||||
expect(fn() => new TotpProvider(digits: 5))
|
||||
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
|
||||
|
||||
expect(fn() => new TotpProvider(digits: 9))
|
||||
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
|
||||
|
||||
expect(fn() => new TotpProvider(windowSize: -1))
|
||||
->toThrow(InvalidArgumentException::class, 'cannot be negative');
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@ use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Core\ParameterTypeValidator;
|
||||
use App\Framework\Examples\MultiPurposeAction;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
use App\Framework\Reflection\WrappedReflectionMethod;
|
||||
use App\Framework\Reflection\Cache\MethodCache;
|
||||
use App\Framework\ReflectionLegacy\WrappedReflectionMethod;
|
||||
use App\Framework\ReflectionLegacy\Cache\MethodCache;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
|
||||
test('multi-purpose method has multiple attributes', function () {
|
||||
|
||||
@@ -5,10 +5,10 @@ declare(strict_types=1);
|
||||
namespace Tests\Framework\Reflection\Cache;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Reflection\Cache\AttributeCache;
|
||||
use App\Framework\Reflection\Cache\ClassCache;
|
||||
use App\Framework\Reflection\Cache\MethodCache;
|
||||
use App\Framework\Reflection\Cache\ParameterCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ClassCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\MethodCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace Tests\Framework\Reflection;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Filesystem\InMemoryStorage;
|
||||
use App\Framework\Reflection\Cache\AttributeCache;
|
||||
use App\Framework\Reflection\Cache\ClassCache;
|
||||
use App\Framework\Reflection\Cache\MetadataCacheManager;
|
||||
use App\Framework\Reflection\Cache\MethodCache;
|
||||
use App\Framework\Reflection\Cache\ParameterCache;
|
||||
use App\Framework\Reflection\ReflectionCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ClassCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\MetadataCacheManager;
|
||||
use App\Framework\ReflectionLegacy\Cache\MethodCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
|
||||
use App\Framework\ReflectionLegacy\ReflectionCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Tests\Framework\Reflection\Support\MemoryLeakDetector;
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace Tests\Framework\Reflection;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Filesystem\InMemoryStorage;
|
||||
use App\Framework\Reflection\Cache\AttributeCache;
|
||||
use App\Framework\Reflection\Cache\ClassCache;
|
||||
use App\Framework\Reflection\Cache\MetadataCacheManager;
|
||||
use App\Framework\Reflection\Cache\MethodCache;
|
||||
use App\Framework\Reflection\Cache\ParameterCache;
|
||||
use App\Framework\Reflection\ReflectionCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ClassCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\MetadataCacheManager;
|
||||
use App\Framework\ReflectionLegacy\Cache\MethodCache;
|
||||
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
|
||||
use App\Framework\ReflectionLegacy\ReflectionCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\ControllerRequestFactory;
|
||||
use App\Framework\Logging\DefaultLogger;
|
||||
use App\Framework\Reflection\ReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\ReflectionProvider;
|
||||
use App\Framework\Router\ParameterProcessor;
|
||||
|
||||
describe('ParameterProcessor Debug', function () {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Reflection\CachedReflectionProvider;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\Validation\GroupAware;
|
||||
use App\Framework\Validation\Rules\Email;
|
||||
use App\Framework\Validation\Rules\Required;
|
||||
|
||||
Reference in New Issue
Block a user