Remove WireGuard integration from production deployment to simplify infrastructure: - Remove docker-compose-direct-access.yml (VPN-bound services) - Remove VPN-only middlewares from Grafana, Prometheus, Portainer - Remove WireGuard middleware definitions from Traefik - Remove WireGuard IPs (10.8.0.0/24) from Traefik forwarded headers All monitoring services now publicly accessible via subdomains: - grafana.michaelschiemer.de (with Grafana native auth) - prometheus.michaelschiemer.de (with Basic Auth) - portainer.michaelschiemer.de (with Portainer native auth) All services use Let's Encrypt SSL certificates via Traefik.
635 lines
22 KiB
PHP
635 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\ApiGateway\ApiGateway;
|
|
use App\Framework\ApiGateway\ApiRequest;
|
|
use App\Framework\ApiGateway\HasAuth;
|
|
use App\Framework\ApiGateway\HasPayload;
|
|
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\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\Retry\RetryStrategy;
|
|
|
|
describe('ApiGateway', function () {
|
|
beforeEach(function () {
|
|
// Track requests for assertion
|
|
$this->capturedRequest = null;
|
|
|
|
// Create mock HttpClient that captures request details
|
|
$this->httpClient = new class($this) implements HttpClient {
|
|
private $testContext;
|
|
|
|
public function __construct($testContext)
|
|
{
|
|
$this->testContext = $testContext;
|
|
}
|
|
|
|
public function send(ClientRequest $request): ClientResponse
|
|
{
|
|
// Capture request for assertions
|
|
$this->testContext->capturedRequest = $request;
|
|
|
|
// Mock successful response
|
|
return new ClientResponse(
|
|
status: Status::OK,
|
|
headers: new Headers(['Content-Type' => 'application/json']),
|
|
body: '{"success": true}'
|
|
);
|
|
}
|
|
};
|
|
|
|
// Create mock dependencies for ApiGateway
|
|
// Create mock dependencies for CircuitBreakerManager
|
|
$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);
|
|
}
|
|
public function set(\App\Framework\Cache\CacheItem ...$items): bool { return true; }
|
|
public function has(\App\Framework\Cache\CacheIdentifier ...$identifiers): array { return []; }
|
|
public function delete(\App\Framework\Cache\CacheIdentifier ...$identifiers): bool { return true; }
|
|
public function forget(\App\Framework\Cache\CacheIdentifier ...$identifiers): bool { return true; }
|
|
public function clear(): bool { return true; }
|
|
public function flush(): bool { return true; }
|
|
public function remember(\App\Framework\Cache\CacheKey $key, callable $callback, ?\App\Framework\Core\ValueObjects\Duration $ttl = null): \App\Framework\Cache\CacheItem
|
|
{
|
|
$value = $callback();
|
|
return new \App\Framework\Cache\CacheItem($key, $value, $ttl);
|
|
}
|
|
};
|
|
|
|
$mockClock = new class implements \App\Framework\DateTime\Clock {
|
|
public function now(): \DateTimeImmutable { return new \DateTimeImmutable(); }
|
|
public function fromTimestamp(\App\Framework\Core\ValueObjects\Timestamp $timestamp): \DateTimeImmutable {
|
|
return new \DateTimeImmutable('@' . $timestamp->toUnixTimestamp());
|
|
}
|
|
public function fromString(string $dateTime, ?string $format = null): \DateTimeImmutable {
|
|
return new \DateTimeImmutable($dateTime);
|
|
}
|
|
public function today(): \DateTimeImmutable { return new \DateTimeImmutable('today'); }
|
|
public function yesterday(): \DateTimeImmutable { return new \DateTimeImmutable('yesterday'); }
|
|
public function tomorrow(): \DateTimeImmutable { return new \DateTimeImmutable('tomorrow'); }
|
|
public function time(): \App\Framework\Core\ValueObjects\Timestamp {
|
|
return \App\Framework\Core\ValueObjects\Timestamp::now();
|
|
}
|
|
};
|
|
|
|
$this->circuitBreakerManager = new \App\Framework\CircuitBreaker\CircuitBreakerManager(
|
|
cache: $mockCache,
|
|
clock: $mockClock
|
|
);
|
|
|
|
$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
|
|
);
|
|
}
|
|
|
|
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->apiGateway = new ApiGateway(
|
|
$this->httpClient,
|
|
$this->circuitBreakerManager,
|
|
$this->metrics,
|
|
$this->operationTracker
|
|
);
|
|
});
|
|
|
|
describe('HasAuth Interface Integration', function () {
|
|
it('applies Basic authentication when ApiRequest implements HasAuth', function () {
|
|
$request = new class implements ApiRequest, HasAuth {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::basic('testuser', 'testpass');
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.basic_auth';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->options->auth)->not->toBeNull();
|
|
expect($this->capturedRequest->options->auth->type)->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'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::custom([
|
|
'X-API-Key' => 'test-api-key-123',
|
|
]);
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.custom_auth';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->options->auth)->not->toBeNull();
|
|
expect($this->capturedRequest->options->auth->type)->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'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.no_auth';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
|
|
expect($response->status)->toBe(Status::OK);
|
|
// No auth should be applied
|
|
expect($this->capturedRequest->options->auth ?? null)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('HasPayload Interface Integration', function () {
|
|
it('includes payload when ApiRequest implements HasPayload', function () {
|
|
$request = new class implements ApiRequest, HasPayload, HasAuth {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::POST;
|
|
}
|
|
|
|
public function getPayload(): array
|
|
{
|
|
return [
|
|
'name' => 'Test User',
|
|
'email' => 'test@example.com',
|
|
];
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::basic('testuser', 'testpass');
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers([
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.with_payload';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
|
|
expect($response->status)->toBe(Status::OK);
|
|
$bodyData = json_decode($this->capturedRequest->body, true);
|
|
expect($bodyData)->toBe([
|
|
'name' => 'Test User',
|
|
'email' => 'test@example.com',
|
|
]);
|
|
});
|
|
|
|
it('does not include payload when ApiRequest does not implement HasPayload', function () {
|
|
$request = new class implements ApiRequest, HasAuth {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::basic('testuser', 'testpass');
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.no_payload';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
|
|
expect($response->status)->toBe(Status::OK);
|
|
// No payload should be sent for GET request
|
|
expect($this->capturedRequest->body)->toBeEmpty();
|
|
});
|
|
});
|
|
|
|
describe('Request Name Tracking', function () {
|
|
it('uses request name from ApiRequest', function () {
|
|
$request = new class implements ApiRequest {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'custom.request.name';
|
|
}
|
|
};
|
|
|
|
expect($request->getRequestName())->toBe('custom.request.name');
|
|
});
|
|
});
|
|
|
|
describe('HTTP Method Support', function () {
|
|
it('supports GET requests', function () {
|
|
$request = new class implements ApiRequest {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.get';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->method)->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'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::POST;
|
|
}
|
|
|
|
public function getPayload(): array
|
|
{
|
|
return ['data' => 'test'];
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Content-Type' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.post';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->method)->toBe('POST');
|
|
$bodyData = json_decode($this->capturedRequest->body, true);
|
|
expect($bodyData)->toBe(['data' => 'test']);
|
|
});
|
|
|
|
it('supports DELETE requests', function () {
|
|
$request = new class implements ApiRequest, HasAuth {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::DELETE;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::basic('user', 'pass');
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Accept' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.delete';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->method)->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'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::PATCH;
|
|
}
|
|
|
|
public function getPayload(): array
|
|
{
|
|
return ['name' => 'Updated'];
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getAuth(): AuthConfig
|
|
{
|
|
return AuthConfig::basic('user', 'pass');
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers(['Content-Type' => 'application/json']);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.patch';
|
|
}
|
|
};
|
|
|
|
$response = $this->apiGateway->send($request);
|
|
expect($response->status)->toBe(Status::OK);
|
|
expect($this->capturedRequest->method)->toBe('PATCH');
|
|
$bodyData = json_decode($this->capturedRequest->body, true);
|
|
expect($bodyData)->toBe(['name' => 'Updated']);
|
|
});
|
|
});
|
|
|
|
describe('Headers Configuration', function () {
|
|
it('includes custom headers from ApiRequest', function () {
|
|
$request = new class implements ApiRequest {
|
|
public function getEndpoint(): ApiEndpoint
|
|
{
|
|
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
|
|
}
|
|
|
|
public function getMethod(): HttpMethod
|
|
{
|
|
return HttpMethod::GET;
|
|
}
|
|
|
|
public function getTimeout(): Duration
|
|
{
|
|
return Duration::fromSeconds(10);
|
|
}
|
|
|
|
public function getRetryStrategy(): ?RetryStrategy
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function getHeaders(): Headers
|
|
{
|
|
return new Headers([
|
|
'Accept' => 'application/json',
|
|
'X-Custom-Header' => 'custom-value',
|
|
'X-API-Version' => '2.0',
|
|
]);
|
|
}
|
|
|
|
public function getRequestName(): string
|
|
{
|
|
return 'test.custom_headers';
|
|
}
|
|
};
|
|
|
|
$headers = $request->getHeaders();
|
|
expect($headers->get('Accept'))->toBe(['application/json']);
|
|
expect($headers->get('X-Custom-Header'))->toBe(['custom-value']);
|
|
expect($headers->get('X-API-Version'))->toBe(['2.0']);
|
|
});
|
|
});
|
|
});
|