diff --git a/composer.json b/composer.json index 094770d62..b878f96c5 100644 --- a/composer.json +++ b/composer.json @@ -61,6 +61,7 @@ "dereuromark/composer-prefer-lowest": "^0.1.10", "doctrine/annotations": "^1.14.4 || ^2.0.2", "internal/dload": "^1.2.0", + "internal/toml": "^1.0.3", "jetbrains/phpstorm-attributes": "dev-master", "laminas/laminas-code": "^4.16", "phpunit/phpunit": "10.5.45", @@ -84,7 +85,8 @@ "ext-grpc": "For Client calls", "ext-protobuf": "For better performance", "buggregator/trap": "For better debugging", - "roadrunner/psr-logger": "RoadRunner PSR-3 logger integration" + "roadrunner/psr-logger": "RoadRunner PSR-3 logger integration", + "internal/toml": "To load TOML config files" }, "scripts": { "get:binaries": [ diff --git a/src/Common/EnvConfig/Client/ConfigCodec.php b/src/Common/EnvConfig/Client/ConfigCodec.php new file mode 100644 index 000000000..a103d3c5e --- /dev/null +++ b/src/Common/EnvConfig/Client/ConfigCodec.php @@ -0,0 +1,48 @@ +auth = $auth === '' ? null : $auth; + $this->endpoint = $endpoint === '' ? null : $endpoint; + } + + /** + * Merge this codec config with another, with the other config's values taking precedence. + * + * @param self $from Codec config to merge (values from this take precedence) + * @return self New merged codec config + */ + public function mergeWith(self $from): self + { + return new self( + endpoint: $from->endpoint ?? $this->endpoint, + auth: $from->auth ?? $this->auth, + ); + } +} diff --git a/src/Common/EnvConfig/Client/ConfigEnv.php b/src/Common/EnvConfig/Client/ConfigEnv.php new file mode 100644 index 000000000..937cb40b5 --- /dev/null +++ b/src/Common/EnvConfig/Client/ConfigEnv.php @@ -0,0 +1,183 @@ +currentProfile = $currentProfile === '' || $currentProfile === null + ? null + : \strtolower($currentProfile); + $this->configFile = $configFile === '' ? null : $configFile; + } + + public static function fromEnvProvider(EnvProvider $env): self + { + return new self( + new ConfigProfile( + address: $env->get('TEMPORAL_ADDRESS'), + namespace: $env->get('TEMPORAL_NAMESPACE'), + apiKey: $env->get('TEMPORAL_API_KEY'), + tlsConfig: self::fetchTlsConfig($env), + grpcMeta: self::fetchGrpcMeta($env), + codecConfig: self::fetchCodecConfig($env), + ), + $env->get('TEMPORAL_PROFILE'), + $env->get('TEMPORAL_CONFIG_FILE'), + ); + } + + private static function fetchTlsConfig(EnvProvider $env): ?ConfigTls + { + $tls = $env->get('TEMPORAL_TLS'); + $tlsVars = $env->getByPrefix('TEMPORAL_TLS_', stripPrefix: true); + + // If no TLS-related variables are set, return null + if ($tls === null && $tlsVars === []) { + return null; + } + + // Parse TEMPORAL_TLS as boolean + $disabled = null; + if ($tls !== null) { + $tlsEnabled = \filter_var($tls, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + $disabled = $tlsEnabled === null ? null : !$tlsEnabled; + } + + // Check for conflicts: *_PATH and *_DATA cannot be used together + isset($tlsVars['SERVER_CA_CERT_PATH'], $tlsVars['SERVER_CA_CERT_DATA']) and throw new \InvalidArgumentException( + 'Cannot specify both TEMPORAL_TLS_SERVER_CA_CERT_PATH and TEMPORAL_TLS_SERVER_CA_CERT_DATA.', + ); + isset($tlsVars['CLIENT_KEY_PATH'], $tlsVars['CLIENT_KEY_DATA']) and throw new \InvalidArgumentException( + 'Cannot specify both TEMPORAL_TLS_CLIENT_KEY_PATH and TEMPORAL_TLS_CLIENT_KEY_DATA.', + ); + isset($tlsVars['CLIENT_CERT_PATH'], $tlsVars['CLIENT_CERT_DATA']) and throw new \InvalidArgumentException( + 'Cannot specify both TEMPORAL_TLS_CLIENT_CERT_PATH and TEMPORAL_TLS_CLIENT_CERT_DATA.', + ); + + // Priority: *_PATH over *_DATA (same as ConfigToml) + return new ConfigTls( + disabled: $disabled, + rootCerts: $tlsVars['SERVER_CA_CERT_PATH'] ?? $tlsVars['SERVER_CA_CERT_DATA'] ?? null, + privateKey: $tlsVars['CLIENT_KEY_PATH'] ?? $tlsVars['CLIENT_KEY_DATA'] ?? null, + certChain: $tlsVars['CLIENT_CERT_PATH'] ?? $tlsVars['CLIENT_CERT_DATA'] ?? null, + serverName: $tlsVars['SERVER_NAME'] ?? null, + ); + } + + /** + * Fetch gRPC metadata from environment variables. + * + * Reads all environment variables with prefix TEMPORAL_GRPC_META_ + * and converts them to gRPC metadata headers. + * + * Header names are transformed: + * - Converted to lowercase + * - Underscores (_) are replaced with hyphens (-) + * + * Example: TEMPORAL_GRPC_META_X_CUSTOM_HEADER=value + * Results in: ['x-custom-header' => 'value'] + * + * @return array + */ + private static function fetchGrpcMeta(EnvProvider $env): array + { + $meta = $env->getByPrefix('TEMPORAL_GRPC_META_', stripPrefix: true); + $result = []; + + foreach ($meta as $key => $value) { + // Transform header name: lowercase and replace _ with - + /** @var non-empty-string $headerName */ + $headerName = \str_replace('_', '-', $key); + $result[$headerName] = $value; + } + + return $result; + } + + /** + * Fetch codec configuration from environment variables. + * + * Reads TEMPORAL_CODEC_ENDPOINT and TEMPORAL_CODEC_AUTH environment variables. + * + * @return ConfigCodec|null Codec configuration or null if no codec env vars are set + */ + private static function fetchCodecConfig(EnvProvider $env): ?ConfigCodec + { + $endpoint = $env->get('TEMPORAL_CODEC_ENDPOINT'); + $auth = $env->get('TEMPORAL_CODEC_AUTH'); + + // Return null if both are not set + if ($endpoint === null && $auth === null) { + return null; + } + + return new ConfigCodec( + endpoint: $endpoint, + auth: $auth, + ); + } +} diff --git a/src/Common/EnvConfig/Client/ConfigProfile.php b/src/Common/EnvConfig/Client/ConfigProfile.php new file mode 100644 index 000000000..2465107a4 --- /dev/null +++ b/src/Common/EnvConfig/Client/ConfigProfile.php @@ -0,0 +1,227 @@ +> + */ + public readonly array $grpcMeta; + + /** + * Construct a new configuration profile. + * + * Empty strings for address, namespace, and apiKey are automatically converted to null. + * gRPC metadata keys are normalized to lowercase per gRPC specification. + * + * @param string|null $address Server address (empty string converted to null) + * @param string|null $namespace Namespace name (empty string converted to null) + * @param string|\Stringable|null $apiKey API key (empty string converted to null) + * @param ConfigTls|null $tlsConfig TLS/mTLS configuration + * @param array> $grpcMeta gRPC metadata headers + * @param ConfigCodec|null $codecConfig Remote codec configuration (NOT SUPPORTED - will throw exception if used) + * + * @throws CodecNotSupportedException If codec configuration is provided (not supported in PHP SDK) + */ + public function __construct( + ?string $address, + ?string $namespace, + null|string|\Stringable $apiKey, + public readonly ?ConfigTls $tlsConfig = null, + array $grpcMeta = [], + public readonly ?ConfigCodec $codecConfig = null, + ) { + // Normalize empty strings to null + $this->address = $address === '' ? null : $address; + $this->namespace = $namespace === '' ? null : $namespace; + $this->apiKey = $apiKey === '' ? null : $apiKey; + + // Normalize gRPC metadata keys to lowercase per gRPC spec + $meta = []; + foreach ($grpcMeta as $key => $value) { + $meta[\strtolower($key)] = \is_array($value) ? $value : [$value]; + } + $this->grpcMeta = $meta; + + // Validate codec is not configured (not supported in PHP SDK) + $codecConfig?->endpoint === null && $codecConfig?->auth === null or throw new CodecNotSupportedException(); + } + + /** + * Merge this profile with another profile, with the other profile's values taking precedence. + * + * Creates a new profile by combining settings from both profiles. Non-null values from the + * provided config override values from this profile. TLS and codec configurations are deeply merged. + * gRPC metadata arrays are merged with keys normalized to lowercase (per gRPC spec), with + * the other profile's values replacing this profile's values for duplicate keys. + * + * @param self $config Profile to merge with (values from this take precedence) + * @return self New merged profile + */ + public function mergeWith(self $config): self + { + return new self( + address: $config->address ?? $this->address, + namespace: $config->namespace ?? $this->namespace, + apiKey: $config->apiKey ?? $this->apiKey, + tlsConfig: self::mergeTlsConfigs($this->tlsConfig, $config->tlsConfig), + grpcMeta: self::mergeGrpcMeta($this->grpcMeta, $config->grpcMeta), + codecConfig: self::mergeCodecConfigs($this->codecConfig, $config->codecConfig), + ); + } + + /** + * Convert this profile to ClientOptions. + * + * Creates a ClientOptions instance with the namespace from this profile. + * Other ClientOptions properties (identity, queryRejectionCondition) use their default values. + * + * @return ClientOptions Configured client options + */ + public function toClientOptions(): ClientOptions + { + $options = new ClientOptions(); + $this->namespace === null or $options = $options->withNamespace($this->namespace); + + return $options; + } + + /** + * Convert this profile to a configured ServiceClient. + * + * Creates a ServiceClient with proper TLS configuration and API key authentication. + * If tlsConfig is present and not disabled, creates an SSL-enabled client with optional + * mTLS support. If apiKey is present, configures authentication headers. + * + * @return ServiceClient Configured service client ready for use + * @throws \InvalidArgumentException If address is not configured + */ + public function toServiceClient(): ServiceClient + { + $this->address === null and throw new \InvalidArgumentException('Address is required to create ServiceClient'); + + // Determine if TLS should be used + $useTls = $this->tlsConfig !== null && !($this->tlsConfig->disabled ?? false); + + // Create client with or without TLS + $client = $useTls + ? ServiceClient::createSSL( + address: $this->address, + crt: $this->tlsConfig->rootCerts, + clientKey: $this->tlsConfig->privateKey, + clientPem: $this->tlsConfig->certChain, + overrideServerName: $this->tlsConfig->serverName, + ) + : ServiceClient::create($this->address); + + // Add API key if present + $this->apiKey === null or $client = $client->withAuthKey($this->apiKey); + + // Add gRPC metadata support when Context API is available + if ($this->grpcMeta !== []) { + $context = $client->getContext(); + $context = $context->withMetadata(self::mergeGrpcMeta($context->getMetadata(), $this->grpcMeta)); + $client = $client->withContext($context); + } + + return $client; + } + + /** + * Merge two TLS configurations with the second taking precedence. + * + * @param ConfigTls|null $to Base TLS configuration + * @param ConfigTls|null $from TLS configuration to merge (takes precedence) + * @return ConfigTls|null Merged TLS configuration or null if both are null + */ + private static function mergeTlsConfigs(?ConfigTls $to, ?ConfigTls $from): ?ConfigTls + { + return match (true) { + $to === null => $from, + $from === null => $to, + default => $to->mergeWith($from), + }; + } + + /** + * Merge two gRPC metadata arrays with lowercase key normalization. + * + * Keys are normalized to lowercase per gRPC specification. Values from the second array + * replace values from the first array for duplicate keys (case-insensitive). + * + * @param array> $to Base metadata + * @param array> $from Metadata to merge (overrides base) + * @return array> Merged metadata + */ + private static function mergeGrpcMeta(array $to, array $from): array + { + $merged = []; + foreach ($to as $key => $values) { + $merged[\strtolower($key)] = $values; + } + + foreach ($from as $key => $values) { + $merged[\strtolower($key)] = $values; + } + + return $merged; + } + + /** + * Merge two codec configurations with the second taking precedence. + * + * @param ConfigCodec|null $to Base codec configuration + * @param ConfigCodec|null $from Codec configuration to merge (takes precedence) + * @return ConfigCodec|null Merged codec configuration or null if both are null + */ + private static function mergeCodecConfigs(?ConfigCodec $to, ?ConfigCodec $from): ?ConfigCodec + { + return match (true) { + $to === null => $from, + $from === null => $to, + default => $to->mergeWith($from), + }; + } +} diff --git a/src/Common/EnvConfig/Client/ConfigTls.php b/src/Common/EnvConfig/Client/ConfigTls.php new file mode 100644 index 000000000..be661ae8e --- /dev/null +++ b/src/Common/EnvConfig/Client/ConfigTls.php @@ -0,0 +1,61 @@ +rootCerts = $rootCerts === '' ? null : $rootCerts; + $this->privateKey = $privateKey === '' ? null : $privateKey; + $this->certChain = $certChain === '' ? null : $certChain; + $this->serverName = $serverName === '' ? null : $serverName; + } + + public function mergeWith(ConfigTls $from): self + { + return new self( + disabled: $from->disabled ?? $this->disabled, + rootCerts: $from->rootCerts ?? $this->rootCerts, + privateKey: $from->privateKey ?? $this->privateKey, + certChain: $from->certChain ?? $this->certChain, + serverName: $from->serverName ?? $this->serverName, + ); + } +} diff --git a/src/Common/EnvConfig/Client/ConfigToml.php b/src/Common/EnvConfig/Client/ConfigToml.php new file mode 100644 index 000000000..bb2bb05aa --- /dev/null +++ b/src/Common/EnvConfig/Client/ConfigToml.php @@ -0,0 +1,232 @@ + + */ + public readonly array $profiles, + ) {} + + /** + * @param string $toml TOML content + */ + public static function fromString(string $toml): self + { + \class_exists(Toml::class) or throw new TomlParserNotFoundException(); + $data = Toml::parseToArray($toml); + return new self(self::parseProfiles($data['profile'] ?? [])); + } + + /** + * Convert the configuration back to TOML string. + * + * @return string TOML representation of the configuration + */ + public function toToml(): string + { + return (string) Toml::encode([ + 'profile' => \array_map(self::encodeProfile(...), $this->profiles), + ]); + } + + /** + * Assert a condition and throw an exception if it fails. + * + * @param bool $condition The condition to assert. + * @param non-empty-string $message The exception message if the assertion fails. + * @return false Returns false if the assertion passes + * @throws \InvalidArgumentException If the assertion fails. + */ + private static function notAssert(bool $condition, string $message): bool + { + $condition or throw new \InvalidArgumentException($message); + return false; + } + + /** + * Parse profiles from the given configuration array. + * + * @param mixed $profile The profile configuration data. + * @return array + * @throws \InvalidArgumentException If the configuration is invalid. + */ + private static function parseProfiles(mixed $profile): array + { + if (self::notAssert(\is_array($profile), 'The `profile` section must be an array.')) { + return []; + } + + $result = []; + foreach ($profile as $name => $config) { + if ( + self::notAssert(\is_array($config), 'Each profile configuration must be an array.') + || self::notAssert(\strlen($name) > 0, 'Profile name must be a non-empty string.') + ) { + continue; + } + + $apiKey = $config['api_key'] ?? null; + $tls = $config['tls'] ?? null; + $tlsConfig = match (true) { + \is_array($tls) => self::parseTls($tls), + $apiKey !== null || $tls === true => new ConfigTls(), + default => new ConfigTls(disabled: true), + }; + + $result[$name] = new ConfigProfile( + address: $config['address'] ?? null, + namespace: $config['namespace'] ?? null, + apiKey: $apiKey, + tlsConfig: $tlsConfig, + grpcMeta: $config['grpc_meta'] ?? [], + codecConfig: isset($config['codec']) && \is_array($config['codec']) ? self::parseCodec($config['codec']) : null, + ); + } + + return $result; + } + + private static function parseTls(array $tls): ?ConfigTls + { + // cert_data and cert_path must not be used together + $rootCert = $tls['server_ca_cert_path'] ?? $tls['server_ca_cert_data'] ?? null; + $privateKey = $tls['client_key_path'] ?? $tls['client_key_data'] ?? null; + $certChain = $tls['client_cert_path'] ?? $tls['client_cert_data'] ?? null; + + $rootCert === null or self::notAssert( + isset($tls['server_ca_cert_path']) xor isset($tls['server_ca_cert_data']), + 'Cannot specify both `server_ca_cert_path` and `server_ca_cert_data`.', + ); + $privateKey === null or self::notAssert( + isset($tls['client_key_path']) xor isset($tls['client_key_data']), + 'Cannot specify both `client_key_path` and `client_key_data`.', + ); + $certChain === null or self::notAssert( + isset($tls['client_cert_path']) xor isset($tls['client_cert_data']), + 'Cannot specify both `client_cert_path` and `client_cert_data`.', + ); + self::notAssert( + ($privateKey === null) === ($certChain === null), + 'Both `client_key_*` and `client_cert_*` must be specified for mTLS.', + ); + + return new ConfigTls( + disabled: $tls['disabled'] ?? false, + rootCerts: $rootCert, + privateKey: $privateKey, + certChain: $certChain, + serverName: $tls['server_name'] ?? null, + ); + } + + /** + * Parse codec configuration from TOML array. + * + * @param array $codec Codec configuration array + * @return ConfigCodec|null Parsed codec configuration or null if empty + */ + private static function parseCodec(array $codec): ?ConfigCodec + { + $endpoint = $codec['endpoint'] ?? null; + $auth = $codec['auth'] ?? null; + + // Return null if both fields are empty + if ($endpoint === null && $auth === null) { + return null; + } + + return new ConfigCodec( + endpoint: $endpoint, + auth: $auth, + ); + } + + private static function encodeProfile(ConfigProfile $profile): array + { + $result = []; + $profile->address === null or $result['address'] = $profile->address; + $profile->namespace === null or $result['namespace'] = $profile->namespace; + $profile->apiKey === null or $result['api_key'] = (string) $profile->apiKey; + $profile->tlsConfig === null or $result['tls'] = self::encodeTls($profile->tlsConfig); + $profile->grpcMeta === [] or $result['grpc_meta'] = $profile->grpcMeta; + $profile->codecConfig === null or $result['codec'] = self::encodeCodec($profile->codecConfig); + return $result; + } + + private static function encodeTls(ConfigTls $config): array + { + $result = []; + $config->disabled === null or $result['disabled'] = $config->disabled; + $config->rootCerts === null or self::isCertFile($config->rootCerts) + ? $result['server_ca_cert_path'] = $config->rootCerts + : $result['server_ca_cert_data'] = $config->rootCerts; + $config->privateKey === null or self::isCertFile($config->privateKey) + ? $result['client_key_path'] = $config->privateKey + : $result['client_key_data'] = $config->privateKey; + $config->certChain === null or self::isCertFile($config->certChain) + ? $result['client_cert_path'] = $config->certChain + : $result['client_cert_data'] = $config->certChain; + $config->serverName === null or $result['server_name'] = $config->serverName; + return $result; + } + + private static function isCertFile(string $certOrPath): bool + { + return \str_starts_with($certOrPath, '/') + || \str_starts_with($certOrPath, './') + || \str_starts_with($certOrPath, '../'); + } + + private static function encodeCodec(ConfigCodec $config): array + { + $result = []; + $config->endpoint === null or $result['endpoint'] = $config->endpoint; + $config->auth === null or $result['auth'] = $config->auth; + return $result; + } +} diff --git a/src/Common/EnvConfig/ConfigClient.php b/src/Common/EnvConfig/ConfigClient.php new file mode 100644 index 000000000..58707c577 --- /dev/null +++ b/src/Common/EnvConfig/ConfigClient.php @@ -0,0 +1,235 @@ + $profiles Profile configurations keyed by lowercase name + */ + private function __construct( + public readonly array $profiles, + ) {} + + /** + * Load client configuration with optional file and environment variable merging. + * + * This is the primary method for loading configuration with full control over sources. + * + * Loading order (later overrides earlier): + * 1. Profile from TOML file (if $configFile provided, or TEMPORAL_CONFIG_FILE is set, + * or file exists at default platform-specific location) + * 2. Environment variable overrides (if $envProvider provided) + * + * @param non-empty-string|null $profileName Profile name to load. If null, uses TEMPORAL_PROFILE + * environment variable or 'default' as fallback. + * @param non-empty-string|null $configFile Path to TOML config file or TOML content string. + * If null, checks TEMPORAL_CONFIG_FILE env var, then checks if file exists at + * default platform-specific location. + * @param EnvProvider $envProvider Environment variable provider for overrides. + * @param bool $strict Whether to use strict TOML parsing (validates mutual exclusivity, etc.) + * + * @throws ProfileNotFoundException If the requested profile is not found + * @throws InvalidConfigException If configuration file is invalid + */ + public static function load( + ?string $profileName = null, + ?string $configFile = null, + EnvProvider $envProvider = new SystemEnvProvider(), + bool $strict = true, + ): self { + // Load environment config first to get profile name if not specified + $envConfig = ConfigEnv::fromEnvProvider($envProvider); + $profileName ??= $envConfig->currentProfile ?? 'default'; + $profileNameLower = \strtolower($profileName); + + // Determine config file path: explicit > env var > default location + $configFile ??= $envConfig->configFile; + if ($configFile === null) { + $configFile = self::getDefaultConfigPath($envProvider); + $configFile === null or \file_exists($configFile) or $configFile = null; + } + + // Load from file if it exists + $profile = null; + $profiles = []; + if ($configFile !== null) { + $profiles = self::loadFromToml($configFile, $strict)->profiles; + $profile = $profiles[$profileNameLower] ?? null; + } + + // Merge with environment overrides or use env profile + if ($profile !== null) { + $profile = $profile->mergeWith($envConfig->profile); + } elseif ($envConfig->profile->address !== null || $envConfig->profile->namespace !== null) { + // Use env profile only if it has meaningful data + $profile = $envConfig->profile; + } + + // If still no profile found, throw + $profile === null and throw new ProfileNotFoundException($profileName); + + // Store profile with lowercase key + $profiles[$profileNameLower] = $profile; + + return new self($profiles); + } + + /** + * Load client configuration from environment variables only. + * + * Uses TEMPORAL_* environment variables to construct configuration. + * + * @param EnvProvider $envProvider Environment variable provider. + */ + public static function loadFromEnv(EnvProvider $envProvider = new SystemEnvProvider()): self + { + $envConfig = ConfigEnv::fromEnvProvider($envProvider); + $profileName = $envConfig->currentProfile ?? 'default'; + + return new self([$profileName => $envConfig->profile]); + } + + /** + * Load all profiles from a TOML configuration file. + * + * @param non-empty-string $source File path to TOML config or TOML content string + * + * @throws InvalidConfigException If the file cannot be read or TOML is invalid + */ + public static function loadFromToml(string $source): self + { + try { + // Determine if source is a file path or TOML content + $toml = \is_file($source) + ? \file_get_contents($source) + : $source; + + $toml === false and throw new InvalidConfigException("Failed to read configuration file: {$source}"); + + $config = ConfigToml::fromString($toml); + return new self(self::normalizeProfileNames($config->profiles)); + } catch (ConfigException $e) { + throw $e; + } catch (\Throwable $e) { + throw new InvalidConfigException( + "Invalid TOML configuration: {$e->getMessage()}", + previous: $e, + ); + } + } + + /** + * Serialize the client configuration back to TOML format. + * + * @return non-empty-string TOML representation of the configuration + */ + public function toToml(): string + { + return (new ConfigToml( + profiles: $this->profiles, + ))->toToml(); + } + + /** + * Get a profile by name (case-insensitive). + * + * @param non-empty-string $name Profile name + * + * @throws ProfileNotFoundException If profile does not exist + */ + public function getProfile(string $name): ConfigProfile + { + $lower = \strtolower($name); + isset($this->profiles[$lower]) or throw new ProfileNotFoundException($name); + + return $this->profiles[$lower]; + } + + /** + * Check if a profile exists (case-insensitive). + * + * @param non-empty-string $name Profile name + */ + public function hasProfile(string $name): bool + { + return isset($this->profiles[\strtolower($name)]); + } + + /** + * Normalize profile names to lowercase and validate for duplicates. + * + * @param array $profiles Profiles with original case names + * @return array Profiles with lowercase keys + * @throws DuplicateProfileException If duplicate names found + */ + private static function normalizeProfileNames(array $profiles): array + { + $normalized = []; + foreach ($profiles as $name => $profile) { + $lower = \strtolower($name); + isset($normalized[$lower]) and throw new DuplicateProfileException($name, $lower); + $normalized[$lower] = $profile; + } + return $normalized; + } + + /** + * Get the default configuration file path based on the operating system. + * + * Returns the platform-specific path to temporal.toml configuration file: + * - Linux/Unix: $XDG_CONFIG_HOME/temporalio/temporal.toml (default: ~/.config/temporalio/temporal.toml) + * - macOS: ~/Library/Application Support/temporalio/temporal.toml + * - Windows: %APPDATA%/temporalio/temporal.toml + * + * Note: This method returns the expected path regardless of whether the file exists. + * The caller is responsible for checking file existence. + * + * @param EnvProvider $envProvider Environment variable provider + * @return non-empty-string|null Path to default config file, or null if home directory cannot be determined + */ + private static function getDefaultConfigPath(EnvProvider $envProvider): ?string + { + $home = $envProvider->get('HOME') ?? $envProvider->get('USERPROFILE'); + if ($home === null) { + return null; + } + + $configDir = match (\PHP_OS_FAMILY) { + 'Windows' => $envProvider->get('APPDATA') ?? ($home . '\\AppData\\Roaming'), + 'Darwin' => $home . '/Library/Application Support', + default => $envProvider->get('XDG_CONFIG_HOME') ?? ($home . '/.config'), + }; + + return $configDir . \DIRECTORY_SEPARATOR . 'temporalio' . \DIRECTORY_SEPARATOR . 'temporal.toml'; + } +} diff --git a/src/Common/EnvConfig/EnvProvider.php b/src/Common/EnvConfig/EnvProvider.php new file mode 100644 index 000000000..da51b7c45 --- /dev/null +++ b/src/Common/EnvConfig/EnvProvider.php @@ -0,0 +1,38 @@ + + */ + public function getByPrefix(string $prefix, bool $stripPrefix = false): array; +} diff --git a/src/Common/EnvConfig/Exception/CodecNotSupportedException.php b/src/Common/EnvConfig/Exception/CodecNotSupportedException.php new file mode 100644 index 000000000..ef6efe463 --- /dev/null +++ b/src/Common/EnvConfig/Exception/CodecNotSupportedException.php @@ -0,0 +1,25 @@ + $value) { + if (\str_starts_with($key, $prefix)) { + $resultKey = $stripPrefix ? \substr($key, $prefixLength) : $key; + if ($resultKey !== '') { + $result[$resultKey] = (string) $value; + } + } + } + + // Search in getenv() for variables not in $_ENV + // Note: getenv() without arguments returns all environment variables + $envVars = \getenv(); + foreach ($envVars as $key => $value) { + if (\str_starts_with($key, $prefix) && !isset($_ENV[$key])) { + $resultKey = $stripPrefix ? \substr($key, $prefixLength) : $key; + if ($resultKey !== '' && !\array_key_exists($resultKey, $result)) { + $result[$resultKey] = $value; + } + } + } + + return $result; + } +} diff --git a/testing/src/Environment.php b/testing/src/Environment.php index bba483c56..b5ed2bb41 100644 --- a/testing/src/Environment.php +++ b/testing/src/Environment.php @@ -31,6 +31,9 @@ public static function create(): self { $token = \getenv('GITHUB_TOKEN'); + $info = SystemInfo::detect(); + \is_string(\getenv('ROADRUNNER_BINARY')) and $info->rrExecutable = \getenv('ROADRUNNER_BINARY'); + return new self( new ConsoleOutput(), new Downloader(new Filesystem(), HttpClient::create([ @@ -38,7 +41,7 @@ public static function create(): self 'authorization' => $token ? 'token ' . $token : null, ], ])), - SystemInfo::detect(), + $info, ); } diff --git a/tests/Unit/Common/EnvConfig/Client/ConfigCodecTest.php b/tests/Unit/Common/EnvConfig/Client/ConfigCodecTest.php new file mode 100644 index 000000000..784d243d8 --- /dev/null +++ b/tests/Unit/Common/EnvConfig/Client/ConfigCodecTest.php @@ -0,0 +1,130 @@ +endpoint); + self::assertSame('Bearer token123', $codec->auth); + } + + public function testConstructorWithNullValues(): void + { + // Arrange & Act + $codec = new ConfigCodec(); + + // Assert + self::assertNull($codec->endpoint); + self::assertNull($codec->auth); + } + + public function testMergeWithOverridesEndpoint(): void + { + // Arrange + $base = new ConfigCodec( + endpoint: 'https://old.example.com', + auth: 'Bearer old-token', + ); + $override = new ConfigCodec( + endpoint: 'https://new.example.com', + auth: null, + ); + + // Act + $merged = $base->mergeWith($override); + + // Assert + self::assertSame('https://new.example.com', $merged->endpoint); + self::assertSame('Bearer old-token', $merged->auth); + } + + public function testMergeWithOverridesAuth(): void + { + // Arrange + $base = new ConfigCodec( + endpoint: 'https://codec.example.com', + auth: 'Bearer old-token', + ); + $override = new ConfigCodec( + endpoint: null, + auth: 'Bearer new-token', + ); + + // Act + $merged = $base->mergeWith($override); + + // Assert + self::assertSame('https://codec.example.com', $merged->endpoint); + self::assertSame('Bearer new-token', $merged->auth); + } + + public function testMergeWithOverridesBothValues(): void + { + // Arrange + $base = new ConfigCodec( + endpoint: 'https://old.example.com', + auth: 'Bearer old-token', + ); + $override = new ConfigCodec( + endpoint: 'https://new.example.com', + auth: 'Bearer new-token', + ); + + // Act + $merged = $base->mergeWith($override); + + // Assert + self::assertSame('https://new.example.com', $merged->endpoint); + self::assertSame('Bearer new-token', $merged->auth); + } + + public function testMergeWithNullOverrideKeepsBaseValues(): void + { + // Arrange + $base = new ConfigCodec( + endpoint: 'https://codec.example.com', + auth: 'Bearer token123', + ); + $override = new ConfigCodec(); + + // Act + $merged = $base->mergeWith($override); + + // Assert + self::assertSame('https://codec.example.com', $merged->endpoint); + self::assertSame('Bearer token123', $merged->auth); + } + + public function testPropertiesAreReadonly(): void + { + // Arrange + $codec = new ConfigCodec( + endpoint: 'https://codec.example.com', + auth: 'Bearer token123', + ); + + // Assert + $reflection = new \ReflectionClass($codec); + $endpointProperty = $reflection->getProperty('endpoint'); + $authProperty = $reflection->getProperty('auth'); + + self::assertTrue($endpointProperty->isReadOnly()); + self::assertTrue($authProperty->isReadOnly()); + } +} diff --git a/tests/Unit/Common/EnvConfig/Client/ConfigEnvTest.php b/tests/Unit/Common/EnvConfig/Client/ConfigEnvTest.php new file mode 100644 index 000000000..21e80e615 --- /dev/null +++ b/tests/Unit/Common/EnvConfig/Client/ConfigEnvTest.php @@ -0,0 +1,432 @@ + ['localhost:7233']; + yield 'IP address with port' => ['127.0.0.1:7233']; + yield 'domain with port' => ['temporal.example.com:7233']; + yield 'cloud endpoint' => ['my-namespace.tmprl.cloud:7233']; + yield 'hostname without port' => ['temporal-server']; + } + + public static function provideBooleanValues(): \Generator + { + yield 'true string' => ['true', false]; + yield 'false string' => ['false', true]; + yield '1 numeric' => ['1', false]; + yield '0 numeric' => ['0', true]; + yield 'yes string' => ['yes', false]; + yield 'no string' => ['no', true]; + yield 'on string' => ['on', false]; + yield 'off string' => ['off', true]; + } + + public function testEmptyEnvironment(): void + { + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNull($config->currentProfile); + self::assertNull($config->configFile); + self::assertInstanceOf(ConfigProfile::class, $config->profile); + self::assertNull($config->profile->address); + self::assertNull($config->profile->namespace); + self::assertNull($config->profile->apiKey); + self::assertNull($config->profile->tlsConfig); + self::assertSame([], $config->profile->grpcMeta); + } + + public function testReadTemporalAddress(): void + { + $this->envProvider->set('TEMPORAL_ADDRESS', 'localhost:7233'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('localhost:7233', $config->profile->address); + } + + public function testReadTemporalNamespace(): void + { + $this->envProvider->set('TEMPORAL_NAMESPACE', 'my-namespace'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('my-namespace', $config->profile->namespace); + } + + public function testReadTemporalApiKey(): void + { + $this->envProvider->set('TEMPORAL_API_KEY', 'secret-key-123'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('secret-key-123', $config->profile->apiKey); + } + + public function testReadTemporalProfile(): void + { + $this->envProvider->set('TEMPORAL_PROFILE', 'production'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('production', $config->currentProfile); + } + + public function testReadTemporalConfigFile(): void + { + $this->envProvider->set('TEMPORAL_CONFIG_FILE', '/path/to/config.toml'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('/path/to/config.toml', $config->configFile); + } + + public function testReadMultipleEnvironmentVariables(): void + { + $this->envProvider->set('TEMPORAL_ADDRESS', 'cloud.temporal.io:7233'); + $this->envProvider->set('TEMPORAL_NAMESPACE', 'production-ns'); + $this->envProvider->set('TEMPORAL_API_KEY', 'prod-key'); + $this->envProvider->set('TEMPORAL_PROFILE', 'cloud'); + $this->envProvider->set('TEMPORAL_CONFIG_FILE', '/etc/temporal/config.toml'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame('cloud.temporal.io:7233', $config->profile->address); + self::assertSame('production-ns', $config->profile->namespace); + self::assertSame('prod-key', $config->profile->apiKey); + self::assertSame('cloud', $config->currentProfile); + self::assertSame('/etc/temporal/config.toml', $config->configFile); + } + + #[DataProvider('provideAddressFormats')] + public function testAddressFormatVariations(string $address): void + { + $this->envProvider->set('TEMPORAL_ADDRESS', $address); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame($address, $config->profile->address); + } + + public function testTlsConfigNotSetReturnsNull(): void + { + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNull($config->profile->tlsConfig); + } + + public function testTlsEnabledAsBoolean(): void + { + $this->envProvider->set('TEMPORAL_TLS', 'true'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertFalse($config->profile->tlsConfig->disabled); + } + + public function testTlsDisabledAsBoolean(): void + { + $this->envProvider->set('TEMPORAL_TLS', 'false'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertTrue($config->profile->tlsConfig->disabled); + } + + #[DataProvider('provideBooleanValues')] + public function testTlsBooleanVariations(string $value, bool $expectedDisabled): void + { + $this->envProvider->set('TEMPORAL_TLS', $value); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame($expectedDisabled, $config->profile->tlsConfig->disabled); + } + + public function testTlsClientCertPath(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_PATH', '/path/to/client.crt'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('/path/to/client.crt', $config->profile->tlsConfig->certChain); + } + + public function testTlsClientKeyPath(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_PATH', '/path/to/client.key'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('/path/to/client.key', $config->profile->tlsConfig->privateKey); + } + + public function testTlsServerCaCertPath(): void + { + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_PATH', '/path/to/ca.crt'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('/path/to/ca.crt', $config->profile->tlsConfig->rootCerts); + } + + public function testTlsServerName(): void + { + $this->envProvider->set('TEMPORAL_TLS_SERVER_NAME', 'temporal.example.com'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('temporal.example.com', $config->profile->tlsConfig->serverName); + } + + public function testTlsFullConfiguration(): void + { + $this->envProvider->set('TEMPORAL_TLS', 'true'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_PATH', '/certs/client.crt'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_PATH', '/certs/client.key'); + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_PATH', '/certs/ca.crt'); + $this->envProvider->set('TEMPORAL_TLS_SERVER_NAME', 'my-temporal.cloud'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertFalse($config->profile->tlsConfig->disabled); + self::assertSame('/certs/client.crt', $config->profile->tlsConfig->certChain); + self::assertSame('/certs/client.key', $config->profile->tlsConfig->privateKey); + self::assertSame('/certs/ca.crt', $config->profile->tlsConfig->rootCerts); + self::assertSame('my-temporal.cloud', $config->profile->tlsConfig->serverName); + } + + public function testTlsConfigWithOnlyCertificates(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_PATH', '/certs/client.crt'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_PATH', '/certs/client.key'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertNull($config->profile->tlsConfig->disabled); + self::assertSame('/certs/client.crt', $config->profile->tlsConfig->certChain); + self::assertSame('/certs/client.key', $config->profile->tlsConfig->privateKey); + self::assertNull($config->profile->tlsConfig->rootCerts); + self::assertNull($config->profile->tlsConfig->serverName); + } + + public function testTlsClientCertData(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_DATA', '-----BEGIN CERTIFICATE-----'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('-----BEGIN CERTIFICATE-----', $config->profile->tlsConfig->certChain); + } + + public function testTlsClientKeyData(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_DATA', '-----BEGIN PRIVATE KEY-----'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('-----BEGIN PRIVATE KEY-----', $config->profile->tlsConfig->privateKey); + } + + public function testTlsServerCaCertData(): void + { + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_DATA', '-----BEGIN CERTIFICATE-----'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertSame('-----BEGIN CERTIFICATE-----', $config->profile->tlsConfig->rootCerts); + } + + public function testTlsFullConfigurationWithData(): void + { + $this->envProvider->set('TEMPORAL_TLS', 'true'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_DATA', '-----BEGIN CERTIFICATE-----\ncert-data\n-----END CERTIFICATE-----'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_DATA', '-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----'); + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_DATA', '-----BEGIN CA CERT-----\nca-data\n-----END CA CERT-----'); + $this->envProvider->set('TEMPORAL_TLS_SERVER_NAME', 'temporal.cloud'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertNotNull($config->profile->tlsConfig); + self::assertFalse($config->profile->tlsConfig->disabled); + self::assertSame('-----BEGIN CERTIFICATE-----\ncert-data\n-----END CERTIFICATE-----', $config->profile->tlsConfig->certChain); + self::assertSame('-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----', $config->profile->tlsConfig->privateKey); + self::assertSame('-----BEGIN CA CERT-----\nca-data\n-----END CA CERT-----', $config->profile->tlsConfig->rootCerts); + self::assertSame('temporal.cloud', $config->profile->tlsConfig->serverName); + } + + public function testTlsClientCertPathAndDataConflict(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_PATH', '/path/to/cert.crt'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_CERT_DATA', '-----BEGIN CERTIFICATE-----'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both TEMPORAL_TLS_CLIENT_CERT_PATH and TEMPORAL_TLS_CLIENT_CERT_DATA.'); + + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testTlsClientKeyPathAndDataConflict(): void + { + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_PATH', '/path/to/key.key'); + $this->envProvider->set('TEMPORAL_TLS_CLIENT_KEY_DATA', '-----BEGIN PRIVATE KEY-----'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both TEMPORAL_TLS_CLIENT_KEY_PATH and TEMPORAL_TLS_CLIENT_KEY_DATA.'); + + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testTlsServerCaCertPathAndDataConflict(): void + { + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_PATH', '/path/to/ca.crt'); + $this->envProvider->set('TEMPORAL_TLS_SERVER_CA_CERT_DATA', '-----BEGIN CERTIFICATE-----'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both TEMPORAL_TLS_SERVER_CA_CERT_PATH and TEMPORAL_TLS_SERVER_CA_CERT_DATA.'); + + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testGrpcMetaEmpty(): void + { + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame([], $config->profile->grpcMeta); + } + + public function testGrpcMetaSingleHeader(): void + { + $this->envProvider->set('TEMPORAL_GRPC_META_X_CUSTOM_HEADER', 'custom-value'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame(['x-custom-header' => ['custom-value']], $config->profile->grpcMeta); + } + + public function testGrpcMetaMultipleHeaders(): void + { + $this->envProvider->set('TEMPORAL_GRPC_META_X_API_KEY', 'secret-key'); + $this->envProvider->set('TEMPORAL_GRPC_META_X_CLIENT_ID', 'client-123'); + $this->envProvider->set('TEMPORAL_GRPC_META_X_REQUEST_ID', 'req-456'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame([ + 'x-api-key' => ['secret-key'], + 'x-client-id' => ['client-123'], + 'x-request-id' => ['req-456'], + ], $config->profile->grpcMeta); + } + + public function testGrpcMetaWithSpecialCharacters(): void + { + $this->envProvider->set('TEMPORAL_GRPC_META_AUTHORIZATION', 'Bearer token123'); + $this->envProvider->set('TEMPORAL_GRPC_META_CONTENT_TYPE', 'application/json'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame([ + 'authorization' => ['Bearer token123'], + 'content-type' => ['application/json'], + ], $config->profile->grpcMeta); + } + + public function testGrpcMetaDoesNotIncludeOtherTemporalVars(): void + { + $this->envProvider->set('TEMPORAL_ADDRESS', 'localhost:7233'); + $this->envProvider->set('TEMPORAL_NAMESPACE', 'default'); + $this->envProvider->set('TEMPORAL_GRPC_META_X_CUSTOM', 'value'); + + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + self::assertSame(['x-custom' => ['value']], $config->profile->grpcMeta); + } + + public function testThrowsExceptionWhenCodecEndpointIsSet(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_CODEC_ENDPOINT', 'https://codec.example.com'); + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testThrowsExceptionWhenCodecAuthIsSet(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_CODEC_AUTH', 'Bearer token123'); + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testThrowsExceptionWhenBothCodecVarsAreSet(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_CODEC_ENDPOINT', 'https://codec.example.com'); + $this->envProvider->set('TEMPORAL_CODEC_AUTH', 'Bearer token123'); + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigEnv::fromEnvProvider($this->envProvider); + } + + public function testDoesNotThrowExceptionWhenNoCodecVarsAreSet(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_ADDRESS', 'localhost:7233'); + + // Act + $config = ConfigEnv::fromEnvProvider($this->envProvider); + + // Assert + self::assertNull($config->profile->codecConfig); + } + + protected function setUp(): void + { + parent::setUp(); + $this->envProvider = new ArrayEnvProvider(); + } +} diff --git a/tests/Unit/Common/EnvConfig/Client/ConfigTomlTest.php b/tests/Unit/Common/EnvConfig/Client/ConfigTomlTest.php new file mode 100644 index 000000000..34ac5c787 --- /dev/null +++ b/tests/Unit/Common/EnvConfig/Client/ConfigTomlTest.php @@ -0,0 +1,1004 @@ + [ + <<<'TOML' + profile = "invalid" + TOML, + 'The `profile` section must be an array.', + ]; + + yield 'profile configuration is not an array' => [ + <<<'TOML' + [profile] + default = "invalid" + TOML, + 'Each profile configuration must be an array.', + ]; + } + + public static function provideComplexConfigurations(): \Generator + { + yield 'mixed profiles with various configurations' => [ + <<<'TOML' + [profile.minimal] + address = "minimal.example.com:7233" + + [profile.with_namespace] + address = "ns.example.com:7233" + namespace = "my-namespace" + + [profile.with_api_key] + address = "api.example.com:7233" + namespace = "api-namespace" + api_key = "my-secret-key" + + [profile.with_all] + address = "all.example.com:7233" + namespace = "all-namespace" + api_key = "all-secret-key" + [profile.with_all.tls] + server_name = "all-server" + server_ca_cert_data = "all-ca-data" + client_cert_data = "all-cert-data" + client_key_data = "all-key-data" + [profile.with_all.grpc_meta] + header1 = "value1" + header2 = "value2" + TOML, + 4, + [ + 'minimal' => [ + 'address' => 'minimal.example.com:7233', + 'namespace' => null, + 'apiKey' => null, + 'tlsConfig' => [ + 'disabled' => true, + 'serverName' => null, + 'rootCerts' => null, + 'certChain' => null, + 'privateKey' => null, + ], + 'grpcMeta' => [], + ], + 'with_namespace' => [ + 'address' => 'ns.example.com:7233', + 'namespace' => 'my-namespace', + 'apiKey' => null, + 'tlsConfig' => [ + 'disabled' => true, + 'serverName' => null, + 'rootCerts' => null, + 'certChain' => null, + 'privateKey' => null, + ], + ], + 'with_api_key' => [ + 'address' => 'api.example.com:7233', + 'namespace' => 'api-namespace', + 'apiKey' => 'my-secret-key', + 'tlsConfig' => [ + 'disabled' => false, + 'serverName' => null, + 'rootCerts' => null, + 'certChain' => null, + 'privateKey' => null, + ], + ], + 'with_all' => [ + 'address' => 'all.example.com:7233', + 'namespace' => 'all-namespace', + 'apiKey' => 'all-secret-key', + 'grpcMeta' => ['header1' => ['value1'], 'header2' => ['value2']], + 'tlsConfig' => [ + 'disabled' => false, + 'serverName' => 'all-server', + 'rootCerts' => 'all-ca-data', + 'certChain' => 'all-cert-data', + 'privateKey' => 'all-key-data', + ], + ], + ], + ]; + + yield 'profiles with different TLS configurations' => [ + <<<'TOML' + [profile.tls_paths] + address = "paths.example.com:7233" + [profile.tls_paths.tls] + server_ca_cert_path = "path/to/ca.pem" + client_cert_path = "path/to/cert.pem" + client_key_path = "path/to/key.pem" + + [profile.tls_data] + address = "data.example.com:7233" + [profile.tls_data.tls] + server_ca_cert_data = "ca-data" + client_cert_data = "cert-data" + client_key_data = "key-data" + + [profile.tls_mixed] + address = "mixed.example.com:7233" + [profile.tls_mixed.tls] + server_ca_cert_path = "path/to/ca.pem" + client_cert_data = "cert-data" + client_key_data = "key-data" + TOML, + 3, + [ + 'tls_paths' => [ + 'tlsConfig' => [ + 'disabled' => false, + 'rootCerts' => 'path/to/ca.pem', + 'certChain' => 'path/to/cert.pem', + 'privateKey' => 'path/to/key.pem', + ], + ], + 'tls_data' => [ + 'tlsConfig' => [ + 'disabled' => false, + 'rootCerts' => 'ca-data', + 'certChain' => 'cert-data', + 'privateKey' => 'key-data', + ], + ], + 'tls_mixed' => [ + 'tlsConfig' => [ + 'disabled' => false, + 'rootCerts' => 'path/to/ca.pem', + 'certChain' => 'cert-data', + 'privateKey' => 'key-data', + ], + ], + ], + ]; + } + + public function testConstructorParsesDefaultProfileWithTlsPaths(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "my-ns.a1b2c.tmprl.cloud:7233" + namespace = "my-ns.a1b2c" + tls.client_cert_path = "path/to/my/client.pem" + tls.client_key_path = "path/to/my/client.pem" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + self::assertArrayHasKey('default', $config->profiles); + + $defaultProfile = $config->profiles['default']; + self::assertInstanceOf(ConfigProfile::class, $defaultProfile); + self::assertSame('my-ns.a1b2c.tmprl.cloud:7233', $defaultProfile->address); + self::assertSame('my-ns.a1b2c', $defaultProfile->namespace); + self::assertNull($defaultProfile->apiKey); + self::assertSame([], $defaultProfile->grpcMeta); + + self::assertInstanceOf(ConfigTls::class, $defaultProfile->tlsConfig); + self::assertSame('path/to/my/client.pem', $defaultProfile->tlsConfig->privateKey); + self::assertSame('path/to/my/client.pem', $defaultProfile->tlsConfig->certChain); + self::assertNull($defaultProfile->tlsConfig->rootCerts); + self::assertNull($defaultProfile->tlsConfig->serverName); + } + + public function testConstructorParsesMultipleProfiles(): void + { + // Arrange + $toml = <<<'TOML' + [profile.dev] + address = "my-dev-ns.a1b2c.tmprl.cloud:7233" + namespace = "my-dev-ns.a1b2c" + tls.client_cert_path = "path/to/my/dev-cert.pem" + tls.client_key_path = "path/to/my/dev-cert.pem" + + [profile.prod] + address = "my-prod-ns.a1b2c.tmprl.cloud:7233" + namespace = "my-prod-ns.a1b2c" + tls.client_cert_path = "path/to/my/prod-cert.pem" + tls.client_key_path = "path/to/my/prod-cert.pem" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(2, $config->profiles); + self::assertArrayHasKey('dev', $config->profiles); + self::assertArrayHasKey('prod', $config->profiles); + + $devProfile = $config->profiles['dev']; + self::assertSame('my-dev-ns.a1b2c.tmprl.cloud:7233', $devProfile->address); + self::assertSame('my-dev-ns.a1b2c', $devProfile->namespace); + + $prodProfile = $config->profiles['prod']; + self::assertSame('my-prod-ns.a1b2c.tmprl.cloud:7233', $prodProfile->address); + self::assertSame('my-prod-ns.a1b2c', $prodProfile->namespace); + } + + public function testConstructorParsesProfileWithApiKeyAndGrpcMeta(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "default-address" + namespace = "default-namespace" + [profile.default.tls] + disabled = true + + [profile.custom] + address = "custom-address" + namespace = "custom-namespace" + api_key = "custom-api-key" + [profile.custom.tls] + server_name = "custom-server-name" + [profile.custom.grpc_meta] + custom-header = "custom-value" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(2, $config->profiles); + + $defaultProfile = $config->profiles['default']; + self::assertSame('default-address', $defaultProfile->address); + self::assertSame('default-namespace', $defaultProfile->namespace); + self::assertNull($defaultProfile->apiKey); + self::assertInstanceOf(ConfigTls::class, $defaultProfile->tlsConfig); + self::assertTrue($defaultProfile->tlsConfig->disabled); + self::assertSame([], $defaultProfile->grpcMeta); + + $customProfile = $config->profiles['custom']; + self::assertSame('custom-address', $customProfile->address); + self::assertSame('custom-namespace', $customProfile->namespace); + self::assertSame('custom-api-key', $customProfile->apiKey); + self::assertSame(['custom-header' => ['custom-value']], $customProfile->grpcMeta); + + self::assertInstanceOf(ConfigTls::class, $customProfile->tlsConfig); + self::assertSame('custom-server-name', $customProfile->tlsConfig->serverName); + self::assertNull($customProfile->tlsConfig->rootCerts); + self::assertNull($customProfile->tlsConfig->privateKey); + self::assertNull($customProfile->tlsConfig->certChain); + } + + public function testConstructorParsesProfileWithTlsDisabled(): void + { + // Arrange + $toml = <<<'TOML' + [profile.tls_disabled] + address = "localhost:1234" + [profile.tls_disabled.tls] + disabled = true + server_name = "should-be-ignored" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + self::assertArrayHasKey('tls_disabled', $config->profiles); + + $profile = $config->profiles['tls_disabled']; + self::assertSame('localhost:1234', $profile->address); + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertTrue($profile->tlsConfig->disabled, 'TLS should be disabled'); + self::assertSame('should-be-ignored', $profile->tlsConfig->serverName); + } + + public function testConstructorParsesProfileWithTlsCertData(): void + { + // Arrange + $toml = <<<'TOML' + [profile.tls_with_certs] + address = "localhost:5678" + [profile.tls_with_certs.tls] + server_name = "custom-server" + server_ca_cert_data = "ca-pem-data" + client_cert_data = "client-crt-data" + client_key_data = "client-key-data" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + self::assertArrayHasKey('tls_with_certs', $config->profiles); + + $profile = $config->profiles['tls_with_certs']; + self::assertSame('localhost:5678', $profile->address); + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertSame('custom-server', $profile->tlsConfig->serverName); + self::assertSame('ca-pem-data', $profile->tlsConfig->rootCerts); + self::assertSame('client-crt-data', $profile->tlsConfig->certChain); + self::assertSame('client-key-data', $profile->tlsConfig->privateKey); + } + + public function testConstructorHandlesEmptyToml(): void + { + // Arrange + $toml = ''; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertEmpty($config->profiles); + } + + public function testConstructorHandlesTomlWithoutProfiles(): void + { + // Arrange + $toml = <<<'TOML' + [some_other_section] + key = "value" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertEmpty($config->profiles); + } + + public function testConstructorInStrictModeThrowsExceptionForDuplicateCertPath(): void + { + // Arrange + $toml = <<<'TOML' + [profile.invalid] + address = "localhost:1234" + [profile.invalid.tls] + server_ca_cert_path = "path/to/ca.pem" + server_ca_cert_data = "ca-pem-data" + TOML; + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both `server_ca_cert_path` and `server_ca_cert_data`.'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorInStrictModeThrowsExceptionForDuplicateClientKeyPath(): void + { + // Arrange + $toml = <<<'TOML' + [profile.invalid] + address = "localhost:1234" + [profile.invalid.tls] + client_key_path = "path/to/key.pem" + client_key_data = "key-data" + TOML; + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both `client_key_path` and `client_key_data`.'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorInStrictModeThrowsExceptionForDuplicateClientCertPath(): void + { + // Arrange + $toml = <<<'TOML' + [profile.invalid] + address = "localhost:1234" + [profile.invalid.tls] + client_cert_path = "path/to/cert.pem" + client_cert_data = "cert-data" + TOML; + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify both `client_cert_path` and `client_cert_data`.'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorInStrictModeThrowsExceptionForMissingClientKey(): void + { + // Arrange + $toml = <<<'TOML' + [profile.invalid] + address = "localhost:1234" + [profile.invalid.tls] + client_cert_data = "cert-data" + TOML; + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Both `client_key_*` and `client_cert_*` must be specified for mTLS.'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorInStrictModeThrowsExceptionForMissingClientCert(): void + { + // Arrange + $toml = <<<'TOML' + [profile.invalid] + address = "localhost:1234" + [profile.invalid.tls] + client_key_data = "key-data" + TOML; + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Both `client_key_*` and `client_cert_*` must be specified for mTLS.'); + + // Act + ConfigToml::fromString($toml); + } + + #[DataProvider('provideInvalidProfileStructures')] + public function testConstructorInStrictModeThrowsExceptionForInvalidStructure( + string $toml, + string $expectedMessage, + ): void { + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + // Act + ConfigToml::fromString($toml); + } + + #[DataProvider('provideComplexConfigurations')] + public function testConstructorHandlesComplexConfigurations( + string $toml, + int $expectedProfileCount, + array $profileChecks, + ): void { + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount($expectedProfileCount, $config->profiles); + + foreach ($profileChecks as $profileName => $checks) { + self::assertArrayHasKey($profileName, $config->profiles); + $profile = $config->profiles[$profileName]; + + foreach ($checks as $property => $expectedValue) { + if ($property === 'tlsConfig') { + if ($expectedValue === null) { + self::assertNull($profile->tlsConfig); + } else { + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + foreach ($expectedValue as $tlsProperty => $tlsValue) { + self::assertSame($tlsValue, $profile->tlsConfig->$tlsProperty); + } + } + } else { + self::assertSame($expectedValue, $profile->$property); + } + } + } + } + + public function testConstructorParsesProfilesWithOnlyTlsServerName(): void + { + // Arrange + $toml = <<<'TOML' + [profile.server_name_only] + address = "example.com:7233" + [profile.server_name_only.tls] + server_name = "custom-server-name" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['server_name_only']; + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertSame('custom-server-name', $profile->tlsConfig->serverName); + self::assertNull($profile->tlsConfig->rootCerts); + self::assertNull($profile->tlsConfig->privateKey); + self::assertNull($profile->tlsConfig->certChain); + } + + public function testConstructorHandlesEmptyGrpcMeta(): void + { + // Arrange + $toml = <<<'TOML' + [profile.empty_meta] + address = "example.com:7233" + [profile.empty_meta.grpc_meta] + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['empty_meta']; + self::assertSame([], $profile->grpcMeta); + } + + public function testConstructorParsesProfileWithTlsEnabledByApiKey(): void + { + // Arrange + $toml = <<<'TOML' + [profile.with_api_key] + address = "api.example.com:7233" + namespace = "api-namespace" + api_key = "my-api-key" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['with_api_key']; + + self::assertSame('api.example.com:7233', $profile->address); + self::assertSame('api-namespace', $profile->namespace); + self::assertSame('my-api-key', $profile->apiKey); + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertFalse($profile->tlsConfig->disabled); + self::assertNull($profile->tlsConfig->serverName); + self::assertNull($profile->tlsConfig->rootCerts); + self::assertNull($profile->tlsConfig->privateKey); + self::assertNull($profile->tlsConfig->certChain); + } + + public function testConstructorParsesProfileWithTlsDisabledByDefault(): void + { + // Arrange + $toml = <<<'TOML' + [profile.minimal] + address = "minimal.example.com:7233" + namespace = "minimal-namespace" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['minimal']; + + self::assertSame('minimal.example.com:7233', $profile->address); + self::assertSame('minimal-namespace', $profile->namespace); + self::assertNull($profile->apiKey); + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertTrue($profile->tlsConfig->disabled); + } + + public function testConstructorParsesProfileWithTlsEnabledExplicitly(): void + { + // Arrange + $toml = <<<'TOML' + [profile.explicit_tls] + address = "tls.example.com:7233" + tls = true + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['explicit_tls']; + + self::assertSame('tls.example.com:7233', $profile->address); + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertFalse($profile->tlsConfig->disabled); + } + + public function testConstructorParsesProfileWithDisabledFalse(): void + { + // Arrange + $toml = <<<'TOML' + [profile.tls_enabled] + address = "enabled.example.com:7233" + [profile.tls_enabled.tls] + disabled = false + server_name = "enabled-server" + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['tls_enabled']; + + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertFalse($profile->tlsConfig->disabled); + self::assertSame('enabled-server', $profile->tlsConfig->serverName); + } + + public function testConstructorThrowsExceptionWhenCodecEndpointIsConfigured(): void + { + // Arrange + $toml = <<<'TOML' + [profile.with_codec] + address = "codec.example.com:7233" + [profile.with_codec.codec] + endpoint = "https://codec.example.com" + TOML; + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorThrowsExceptionWhenCodecAuthIsConfigured(): void + { + // Arrange + $toml = <<<'TOML' + [profile.with_codec_auth] + address = "codec.example.com:7233" + [profile.with_codec_auth.codec] + auth = "Bearer token123" + TOML; + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorThrowsExceptionWhenBothCodecFieldsAreConfigured(): void + { + // Arrange + $toml = <<<'TOML' + [profile.with_full_codec] + address = "codec.example.com:7233" + [profile.with_full_codec.codec] + endpoint = "https://codec.example.com" + auth = "Bearer token123" + TOML; + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigToml::fromString($toml); + } + + public function testConstructorDoesNotThrowExceptionForEmptyCodecSection(): void + { + // Arrange + $toml = <<<'TOML' + [profile.empty_codec] + address = "example.com:7233" + [profile.empty_codec.codec] + TOML; + + // Act + $config = ConfigToml::fromString($toml); + + // Assert + self::assertCount(1, $config->profiles); + $profile = $config->profiles['empty_codec']; + self::assertNull($profile->codecConfig); + } + + public function testToTomlRoundTripWithMinimalProfile(): void + { + // Arrange: Create a minimal profile + $original = new ConfigToml([ + 'minimal' => new ConfigProfile( + address: 'localhost:7233', + namespace: null, + apiKey: null, + tlsConfig: new ConfigTls(disabled: true), + grpcMeta: [], + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Verify profiles match + self::assertCount(1, $roundTrip->profiles); + self::assertArrayHasKey('minimal', $roundTrip->profiles); + + $profile = $roundTrip->profiles['minimal']; + self::assertSame('localhost:7233', $profile->address); + self::assertNull($profile->namespace); + self::assertNull($profile->apiKey); + self::assertTrue($profile->tlsConfig->disabled); + self::assertSame([], $profile->grpcMeta); + } + + public function testToTomlRoundTripWithFullProfile(): void + { + // Arrange: Create a profile with all fields + $original = new ConfigToml([ + 'full' => new ConfigProfile( + address: 'full.example.com:7233', + namespace: 'full-namespace', + apiKey: 'full-api-key', + tlsConfig: new ConfigTls( + disabled: false, + rootCerts: 'ca-cert-data', + privateKey: 'private-key-data', + certChain: 'cert-chain-data', + serverName: 'custom-server', + ), + grpcMeta: [ + 'header1' => ['value1'], + 'header2' => ['value2'], + ], + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Verify all fields match + self::assertCount(1, $roundTrip->profiles); + $profile = $roundTrip->profiles['full']; + + self::assertSame('full.example.com:7233', $profile->address); + self::assertSame('full-namespace', $profile->namespace); + self::assertSame('full-api-key', $profile->apiKey); + self::assertSame(['header1' => ['value1'], 'header2' => ['value2']], $profile->grpcMeta); + + self::assertFalse($profile->tlsConfig->disabled); + self::assertSame('ca-cert-data', $profile->tlsConfig->rootCerts); + self::assertSame('private-key-data', $profile->tlsConfig->privateKey); + self::assertSame('cert-chain-data', $profile->tlsConfig->certChain); + self::assertSame('custom-server', $profile->tlsConfig->serverName); + } + + public function testToTomlRoundTripWithMultipleProfiles(): void + { + // Arrange: Create multiple profiles with different configurations + $original = new ConfigToml([ + 'dev' => new ConfigProfile( + address: 'dev.example.com:7233', + namespace: 'dev-ns', + apiKey: null, + tlsConfig: new ConfigTls(disabled: true), + ), + 'staging' => new ConfigProfile( + address: 'staging.example.com:7233', + namespace: 'staging-ns', + apiKey: 'staging-key', + tlsConfig: new ConfigTls(disabled: false), + ), + 'prod' => new ConfigProfile( + address: 'prod.example.com:7233', + namespace: 'prod-ns', + apiKey: 'prod-key', + tlsConfig: new ConfigTls( + disabled: false, + rootCerts: 'prod-ca', + privateKey: 'prod-key', + certChain: 'prod-cert', + serverName: 'prod-server', + ), + grpcMeta: ['authorization' => ['Bearer token']], + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Verify all profiles match + self::assertCount(3, $roundTrip->profiles); + + // Dev profile + $dev = $roundTrip->profiles['dev']; + self::assertSame('dev.example.com:7233', $dev->address); + self::assertSame('dev-ns', $dev->namespace); + self::assertNull($dev->apiKey); + self::assertTrue($dev->tlsConfig->disabled); + + // Staging profile + $staging = $roundTrip->profiles['staging']; + self::assertSame('staging.example.com:7233', $staging->address); + self::assertSame('staging-ns', $staging->namespace); + self::assertSame('staging-key', $staging->apiKey); + self::assertFalse($staging->tlsConfig->disabled); + + // Prod profile + $prod = $roundTrip->profiles['prod']; + self::assertSame('prod.example.com:7233', $prod->address); + self::assertSame('prod-ns', $prod->namespace); + self::assertSame('prod-key', $prod->apiKey); + self::assertFalse($prod->tlsConfig->disabled); + self::assertSame('prod-ca', $prod->tlsConfig->rootCerts); + self::assertSame('prod-key', $prod->tlsConfig->privateKey); + self::assertSame('prod-cert', $prod->tlsConfig->certChain); + self::assertSame('prod-server', $prod->tlsConfig->serverName); + self::assertSame(['authorization' => ['Bearer token']], $prod->grpcMeta); + } + + public function testToTomlRoundTripWithTlsEnabledByApiKey(): void + { + // Arrange: Profile with API key (should auto-enable TLS) + $original = new ConfigToml([ + 'cloud' => new ConfigProfile( + address: 'cloud.example.com:7233', + namespace: 'cloud-ns', + apiKey: 'cloud-api-key', + tlsConfig: new ConfigTls(disabled: false), + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: TLS should remain enabled + self::assertCount(1, $roundTrip->profiles); + $profile = $roundTrip->profiles['cloud']; + self::assertSame('cloud-api-key', $profile->apiKey); + self::assertFalse($profile->tlsConfig->disabled); + } + + public function testToTomlRoundTripWithMixedTlsConfigurations(): void + { + // Arrange: Different TLS configurations + $original = new ConfigToml([ + 'tls_disabled' => new ConfigProfile( + address: 'a.example.com:7233', + namespace: 'a', + apiKey: null, + tlsConfig: new ConfigTls(disabled: true), + ), + 'tls_enabled' => new ConfigProfile( + address: 'b.example.com:7233', + namespace: 'b', + apiKey: null, + tlsConfig: new ConfigTls(disabled: false), + ), + 'tls_with_server_name' => new ConfigProfile( + address: 'c.example.com:7233', + namespace: 'c', + apiKey: null, + tlsConfig: new ConfigTls( + disabled: false, + serverName: 'custom-server', + ), + ), + 'tls_with_certs' => new ConfigProfile( + address: 'd.example.com:7233', + namespace: 'd', + apiKey: null, + tlsConfig: new ConfigTls( + disabled: false, + rootCerts: 'ca-data', + privateKey: 'key-data', + certChain: 'cert-data', + ), + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Verify all TLS configurations match + self::assertCount(4, $roundTrip->profiles); + + self::assertTrue($roundTrip->profiles['tls_disabled']->tlsConfig->disabled); + + self::assertFalse($roundTrip->profiles['tls_enabled']->tlsConfig->disabled); + + $withServerName = $roundTrip->profiles['tls_with_server_name']; + self::assertFalse($withServerName->tlsConfig->disabled); + self::assertSame('custom-server', $withServerName->tlsConfig->serverName); + + $withCerts = $roundTrip->profiles['tls_with_certs']; + self::assertFalse($withCerts->tlsConfig->disabled); + self::assertSame('ca-data', $withCerts->tlsConfig->rootCerts); + self::assertSame('key-data', $withCerts->tlsConfig->privateKey); + self::assertSame('cert-data', $withCerts->tlsConfig->certChain); + } + + public function testToTomlRoundTripWithGrpcMetadata(): void + { + // Arrange: Profile with various gRPC metadata + $original = new ConfigToml([ + 'with_meta' => new ConfigProfile( + address: 'meta.example.com:7233', + namespace: 'meta-ns', + apiKey: null, + tlsConfig: new ConfigTls(disabled: true), + grpcMeta: [ + 'authorization' => ['Bearer token123'], + 'x-custom-header' => ['custom-value'], + 'x-multi-header' => ['value1', 'value2'], + ], + ), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Verify gRPC metadata is preserved + self::assertCount(1, $roundTrip->profiles); + $profile = $roundTrip->profiles['with_meta']; + + self::assertArrayHasKey('authorization', $profile->grpcMeta); + self::assertSame(['Bearer token123'], $profile->grpcMeta['authorization']); + self::assertArrayHasKey('x-custom-header', $profile->grpcMeta); + self::assertSame(['custom-value'], $profile->grpcMeta['x-custom-header']); + self::assertArrayHasKey('x-multi-header', $profile->grpcMeta); + self::assertSame(['value1', 'value2'], $profile->grpcMeta['x-multi-header']); + } + + public function testToTomlRoundTripWithEmptyProfiles(): void + { + // Arrange: Empty configuration + $original = new ConfigToml([]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: Should remain empty + self::assertEmpty($roundTrip->profiles); + } + + public function testToTomlRoundTripPreservesProfileOrder(): void + { + // Arrange: Multiple profiles in specific order + $original = new ConfigToml([ + 'zebra' => new ConfigProfile('z.example.com:7233', 'z', null, new ConfigTls(disabled: true)), + 'alpha' => new ConfigProfile('a.example.com:7233', 'a', null, new ConfigTls(disabled: true)), + 'beta' => new ConfigProfile('b.example.com:7233', 'b', null, new ConfigTls(disabled: true)), + ]); + + // Act: Convert to TOML and back + $tomlString = $original->toToml(); + $roundTrip = ConfigToml::fromString($tomlString); + + // Assert: All profiles should be present (order may vary due to TOML encoding) + self::assertCount(3, $roundTrip->profiles); + self::assertArrayHasKey('zebra', $roundTrip->profiles); + self::assertArrayHasKey('alpha', $roundTrip->profiles); + self::assertArrayHasKey('beta', $roundTrip->profiles); + } +} diff --git a/tests/Unit/Common/EnvConfig/Client/Stub/ArrayEnvProvider.php b/tests/Unit/Common/EnvConfig/Client/Stub/ArrayEnvProvider.php new file mode 100644 index 000000000..b94c7e1a5 --- /dev/null +++ b/tests/Unit/Common/EnvConfig/Client/Stub/ArrayEnvProvider.php @@ -0,0 +1,69 @@ + $variables + */ + public function __construct( + private array $variables = [], + ) {} + + public function get(string $name, ?string $default = null): ?string + { + return $this->variables[$name] ?? $default; + } + + public function getByPrefix(string $prefix, bool $stripPrefix = false): array + { + $result = []; + foreach ($this->variables as $key => $value) { + if (\str_starts_with($key, $prefix)) { + $resultKey = $stripPrefix ? \substr($key, \strlen($prefix)) : $key; + if ($resultKey !== '') { + $result[$resultKey] = $value; + } + } + } + return $result; + } + + /** + * Set environment variable for testing. + * + * @param non-empty-string $name + */ + public function set(string $name, string $value): void + { + $this->variables[$name] = $value; + } + + /** + * Unset environment variable for testing. + * + * @param non-empty-string $name + */ + public function unset(string $name): void + { + unset($this->variables[$name]); + } + + /** + * Clear all environment variables. + */ + public function clear(): void + { + $this->variables = []; + } +} \ No newline at end of file diff --git a/tests/Unit/Common/EnvConfig/ConfigClientTest.php b/tests/Unit/Common/EnvConfig/ConfigClientTest.php new file mode 100644 index 000000000..3bb9b47a0 --- /dev/null +++ b/tests/Unit/Common/EnvConfig/ConfigClientTest.php @@ -0,0 +1,940 @@ + ['default', 'default']; + yield 'uppercase access' => ['default', 'DEFAULT']; + yield 'mixed case access' => ['production', 'PrOdUcTiOn']; + yield 'lowercase access' => ['Production', 'production']; + } + + public function testLoadFromFileWithValidTomlContent(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + namespace = "default" + + [profile.production] + address = "prod.temporal.io:7233" + namespace = "production" + TOML; + + // Act + $config = ConfigClient::loadFromToml($toml); + + // Assert + self::assertTrue($config->hasProfile('default')); + self::assertTrue($config->hasProfile('production')); + + $defaultProfile = $config->getProfile('default'); + self::assertSame('127.0.0.1:7233', $defaultProfile->address); + self::assertSame('default', $defaultProfile->namespace); + + $prodProfile = $config->getProfile('production'); + self::assertSame('prod.temporal.io:7233', $prodProfile->address); + self::assertSame('production', $prodProfile->namespace); + } + + public function testLoadFromFileThrowsExceptionForInvalidToml(): void + { + // Arrange + $invalidToml = <<<'TOML' + [profile.invalid + address = missing_quote + TOML; + + // Assert (before Act for exceptions) + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Invalid TOML configuration'); + + // Act + ConfigClient::loadFromToml($invalidToml); + } + + public function testLoadFromEnvWithSystemEnvProvider(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_ADDRESS', 'localhost:7233'); + $this->envProvider->set('TEMPORAL_NAMESPACE', 'test-namespace'); + $this->envProvider->set('TEMPORAL_API_KEY', 'test-key'); + + // Act + $config = ConfigClient::loadFromEnv($this->envProvider); + + // Assert + self::assertInstanceOf(ConfigClient::class, $config); + $profile = $config->getProfile('default'); + self::assertSame('localhost:7233', $profile->address); + self::assertSame('test-namespace', $profile->namespace); + self::assertSame('test-key', $profile->apiKey); + } + + public function testLoadFromEnvWithEmptyEnvironment(): void + { + // Act + $config = ConfigClient::loadFromEnv($this->envProvider); + + // Assert + self::assertInstanceOf(ConfigClient::class, $config); + $profile = $config->getProfile('default'); + self::assertNull($profile->address); + self::assertNull($profile->namespace); + self::assertNull($profile->apiKey); + } + + public function testLoadFromFileOnly(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + namespace = "default" + TOML; + + // Act + $config = ConfigClient::load( + profileName: 'default', + configFile: $toml, + ); + + // Assert + $profile = $config->getProfile('default'); + self::assertSame('127.0.0.1:7233', $profile->address); + self::assertSame('default', $profile->namespace); + } + + public function testLoadWithEnvOverrides(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + namespace = "default" + TOML; + + $this->envProvider->set('TEMPORAL_ADDRESS', 'override.temporal.io:7233'); + $this->envProvider->set('TEMPORAL_NAMESPACE', 'override-namespace'); + + // Act + $config = ConfigClient::load( + profileName: 'default', + configFile: $toml, + envProvider: $this->envProvider, + ); + + // Assert + $profile = $config->getProfile('default'); + self::assertSame('override.temporal.io:7233', $profile->address); + self::assertSame('override-namespace', $profile->namespace); + } + + public function testLoadUsesTemporalProfileEnvVar(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + + [profile.production] + address = "prod.temporal.io:7233" + TOML; + + $this->envProvider->set('TEMPORAL_PROFILE', 'production'); + + // Act + $config = ConfigClient::load( + profileName: null, + configFile: $toml, + envProvider: $this->envProvider, + ); + + // Assert + $profile = $config->getProfile('production'); + self::assertSame('prod.temporal.io:7233', $profile->address); + } + + public function testLoadDefaultsToDefaultProfile(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + TOML; + + // Act + $config = ConfigClient::load( + profileName: null, + configFile: $toml, + envProvider: $this->envProvider, + ); + + // Assert + $profile = $config->getProfile('default'); + self::assertSame('127.0.0.1:7233', $profile->address); + } + + public function testLoadThrowsExceptionWhenProfileNotFound(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + TOML; + + // Assert (before Act for exceptions) + $this->expectException(ProfileNotFoundException::class); + $this->expectExceptionMessage("Profile 'production' not found"); + + // Act + ConfigClient::load( + profileName: 'production', + configFile: $toml, + ); + } + + public function testLoadFromEnvOnlyWhenNoFileProvided(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_ADDRESS', 'env.temporal.io:7233'); + $this->envProvider->set('TEMPORAL_NAMESPACE', 'env-namespace'); + + // Act + $config = ConfigClient::load( + profileName: 'default', + configFile: null, + envProvider: $this->envProvider, + ); + + // Assert + $profile = $config->getProfile('default'); + self::assertSame('env.temporal.io:7233', $profile->address); + self::assertSame('env-namespace', $profile->namespace); + } + + #[DataProvider('provideCaseInsensitiveProfileNames')] + public function testGetProfileIsCaseInsensitive(string $storeName, string $accessName): void + { + // Arrange + $toml = "[profile.{$storeName}]\naddress = \"127.0.0.1:7233\""; + $config = ConfigClient::loadFromToml($toml); + + // Act + $profile = $config->getProfile($accessName); + + // Assert + self::assertSame('127.0.0.1:7233', $profile->address); + } + + #[DataProvider('provideCaseInsensitiveProfileNames')] + public function testHasProfileIsCaseInsensitive(string $storeName, string $accessName): void + { + // Arrange + $toml = "[profile.{$storeName}]\naddress = \"127.0.0.1:7233\""; + $config = ConfigClient::loadFromToml($toml); + + // Act & Assert + self::assertTrue($config->hasProfile($accessName)); + } + + public function testGetProfileThrowsExceptionWhenNotFound(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + TOML; + $config = ConfigClient::loadFromToml($toml); + + // Assert (before Act for exceptions) + $this->expectException(ProfileNotFoundException::class); + $this->expectExceptionMessage("Profile 'nonexistent' not found"); + + // Act + $config->getProfile('nonexistent'); + } + + public function testHasProfileReturnsFalseWhenNotFound(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + TOML; + $config = ConfigClient::loadFromToml($toml); + + // Act & Assert + self::assertFalse($config->hasProfile('nonexistent')); + } + + public function testDuplicateCaseInsensitiveProfileNamesThrowException(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + + [profile.Default] + address = "other.temporal.io:7233" + TOML; + + // Assert (before Act for exceptions) + $this->expectException(DuplicateProfileException::class); + $this->expectExceptionMessage("Duplicate profile name (case-insensitive): 'Default' conflicts with existing 'default'"); + + // Act + ConfigClient::loadFromToml($toml); + } + + public function testToServiceClientWithoutTls(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + tlsConfig: new ConfigTls(disabled: true), + ); + + // Act + $client = $profile->toServiceClient(); + + // Assert + self::assertInstanceOf(ServiceClient::class, $client); + } + + public function testToServiceClientWithTls(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + tlsConfig: new ConfigTls( + disabled: false, + rootCerts: null, + privateKey: null, + certChain: null, + serverName: 'temporal.example.com', + ), + ); + + // Act + $client = $profile->toServiceClient(); + + // Assert + self::assertInstanceOf(ServiceClient::class, $client); + } + + public function testToServiceClientWithApiKey(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: 'test-api-key', + tlsConfig: new ConfigTls(disabled: false), + ); + + // Act + $client = $profile->toServiceClient(); + + // Assert + self::assertInstanceOf(ServiceClient::class, $client); + } + + public function testToServiceClientThrowsExceptionWithoutAddress(): void + { + // Arrange + $profile = new ConfigProfile( + address: null, + namespace: 'test', + apiKey: null, + ); + + // Assert (before Act for exceptions) + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Address is required to create ServiceClient'); + + // Act + $profile->toServiceClient(); + } + + public function testToClientOptionsWithNamespace(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'custom-namespace', + apiKey: null, + ); + + // Act + $options = $profile->toClientOptions(); + + // Assert + self::assertInstanceOf(ClientOptions::class, $options); + self::assertSame('custom-namespace', $options->namespace); + } + + public function testToClientOptionsWithoutNamespace(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: null, + apiKey: null, + ); + + // Act + $options = $profile->toClientOptions(); + + // Assert + self::assertInstanceOf(ClientOptions::class, $options); + self::assertSame(ClientOptions::DEFAULT_NAMESPACE, $options->namespace); + } + + public function testConfigProfileNormalizesEmptyStringsToNull(): void + { + // Arrange & Act + $profile = new ConfigProfile( + address: '', + namespace: '', + apiKey: '', + ); + + // Assert + self::assertNull($profile->address); + self::assertNull($profile->namespace); + self::assertNull($profile->apiKey); + } + + public function testConfigProfileKeepsNonEmptyStrings(): void + { + // Arrange & Act + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test-namespace', + apiKey: 'test-key', + ); + + // Assert + self::assertSame('127.0.0.1:7233', $profile->address); + self::assertSame('test-namespace', $profile->namespace); + self::assertSame('test-key', $profile->apiKey); + } + + public function testProfilesPropertyIsReadonly(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + TOML; + $config = ConfigClient::loadFromToml($toml); + + // Assert + self::assertIsArray($config->profiles); + self::assertCount(1, $config->profiles); + self::assertArrayHasKey('default', $config->profiles); + } + + public function testGrpcMetadataKeysNormalizedToLowercase(): void + { + // Arrange & Act + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + grpcMeta: [ + 'temporal-namespace' => 'value1', + 'TEMPORAL_CLIENT' => 'value2', + 'Custom_Header' => 'value3', + ], + ); + + // Assert + self::assertArrayHasKey('temporal-namespace', $profile->grpcMeta); + self::assertArrayHasKey('temporal_client', $profile->grpcMeta); + self::assertArrayHasKey('custom_header', $profile->grpcMeta); + self::assertSame(['value1'], $profile->grpcMeta['temporal-namespace']); + self::assertSame(['value2'], $profile->grpcMeta['temporal_client']); + self::assertSame(['value3'], $profile->grpcMeta['custom_header']); + } + + public function testGrpcMetadataStringValuesConvertedToArrays(): void + { + // Arrange & Act + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + grpcMeta: [ + 'String-Value' => 'single', + 'Array-Value' => ['multiple', 'values'], + ], + ); + + // Assert + self::assertSame(['single'], $profile->grpcMeta['string-value']); + self::assertSame(['multiple', 'values'], $profile->grpcMeta['array-value']); + } + + public function testGrpcMetadataMergeWithNormalization(): void + { + // Arrange + $profile1 = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + grpcMeta: [ + 'temporal-namespace' => 'value1', + 'custom-header' => 'base', + ], + ); + + $profile2 = new ConfigProfile( + address: null, + namespace: null, + apiKey: null, + grpcMeta: [ + 'TEMPORAL_NAMESPACE' => 'value2', // Same key, different case - should replace + 'New-Header' => 'new', + ], + ); + + // Act + $merged = $profile1->mergeWith($profile2); + + // Assert + self::assertArrayHasKey('temporal_namespace', $merged->grpcMeta); + self::assertArrayHasKey('custom-header', $merged->grpcMeta); + self::assertArrayHasKey('new-header', $merged->grpcMeta); + // Values should be replaced for same key (case-insensitive) + self::assertSame(['value2'], $merged->grpcMeta['temporal_namespace']); + self::assertSame(['base'], $merged->grpcMeta['custom-header']); + self::assertSame(['new'], $merged->grpcMeta['new-header']); + } + + public function testGrpcMetadataMergeReplacesValues(): void + { + // Arrange + $profile1 = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + grpcMeta: [ + 'Header-One' => ['base1', 'base2'], + ], + ); + + $profile2 = new ConfigProfile( + address: null, + namespace: null, + apiKey: null, + grpcMeta: [ + 'header-one' => ['override1'], // Same key, lowercase - should replace completely + ], + ); + + // Act + $merged = $profile1->mergeWith($profile2); + + // Assert + self::assertArrayHasKey('header-one', $merged->grpcMeta); + self::assertSame(['override1'], $merged->grpcMeta['header-one']); + } + + public function testToServiceClientWithGrpcMetadata(): void + { + // Arrange + $profile = new ConfigProfile( + address: '127.0.0.1:7233', + namespace: 'test', + apiKey: null, + grpcMeta: [ + 'custom-header' => 'custom-value', + ], + ); + + // Act + $client = $profile->toServiceClient(); + + // Assert + self::assertInstanceOf(ServiceClient::class, $client); + $context = $client->getContext(); + $metadata = $context->getMetadata(); + self::assertArrayHasKey('custom-header', $metadata); + self::assertSame(['custom-value'], $metadata['custom-header']); + } + + public function testLoadThrowsExceptionWhenCodecIsConfiguredInToml(): void + { + // Arrange + $toml = <<<'TOML' + [profile.default] + address = "127.0.0.1:7233" + [profile.default.codec] + endpoint = "https://codec.example.com" + TOML; + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigClient::load( + profileName: 'default', + configFile: $toml, + envProvider: $this->envProvider, + ); + } + + public function testLoadThrowsExceptionWhenCodecIsConfiguredInEnv(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_ADDRESS', '127.0.0.1:7233'); + $this->envProvider->set('TEMPORAL_CODEC_ENDPOINT', 'https://codec.example.com'); + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigClient::load( + profileName: 'default', + configFile: null, + envProvider: $this->envProvider, + ); + } + + public function testLoadFromEnvThrowsExceptionWhenCodecIsConfigured(): void + { + // Arrange + $this->envProvider->set('TEMPORAL_ADDRESS', '127.0.0.1:7233'); + $this->envProvider->set('TEMPORAL_CODEC_AUTH', 'Bearer token123'); + + // Assert + $this->expectException(CodecNotSupportedException::class); + $this->expectExceptionMessage('Remote codec configuration is not supported in the PHP SDK'); + + // Act + ConfigClient::loadFromEnv($this->envProvider); + } + + public function testToTomlRoundTripWithSingleProfile(): void + { + // Arrange: Load from TOML + $originalToml = <<<'TOML' + [profile.default] + address = "localhost:7233" + namespace = "default-namespace" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify profile matches + self::assertTrue($roundTrip->hasProfile('default')); + $profile = $roundTrip->getProfile('default'); + self::assertSame('localhost:7233', $profile->address); + self::assertSame('default-namespace', $profile->namespace); + } + + public function testToTomlRoundTripWithMultipleProfiles(): void + { + // Arrange: Load from TOML with multiple profiles + $originalToml = <<<'TOML' + [profile.dev] + address = "dev.example.com:7233" + namespace = "dev-namespace" + + [profile.staging] + address = "staging.example.com:7233" + namespace = "staging-namespace" + api_key = "staging-key" + + [profile.prod] + address = "prod.example.com:7233" + namespace = "prod-namespace" + api_key = "prod-key" + [profile.prod.tls] + server_name = "prod-server" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify all profiles match + self::assertCount(3, $roundTrip->profiles); + + $dev = $roundTrip->getProfile('dev'); + self::assertSame('dev.example.com:7233', $dev->address); + self::assertSame('dev-namespace', $dev->namespace); + self::assertNull($dev->apiKey); + + $staging = $roundTrip->getProfile('staging'); + self::assertSame('staging.example.com:7233', $staging->address); + self::assertSame('staging-namespace', $staging->namespace); + self::assertSame('staging-key', $staging->apiKey); + + $prod = $roundTrip->getProfile('prod'); + self::assertSame('prod.example.com:7233', $prod->address); + self::assertSame('prod-namespace', $prod->namespace); + self::assertSame('prod-key', $prod->apiKey); + self::assertSame('prod-server', $prod->tlsConfig->serverName); + } + + public function testToTomlRoundTripWithComplexTlsConfig(): void + { + // Arrange: Load profile with full TLS configuration + $originalToml = <<<'TOML' + [profile.secure] + address = "secure.example.com:7233" + namespace = "secure-namespace" + [profile.secure.tls] + server_ca_cert_data = "ca-cert-content" + client_cert_data = "client-cert-content" + client_key_data = "client-key-content" + server_name = "custom-server" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify TLS configuration is preserved + $profile = $roundTrip->getProfile('secure'); + self::assertSame('secure.example.com:7233', $profile->address); + self::assertSame('secure-namespace', $profile->namespace); + self::assertInstanceOf(ConfigTls::class, $profile->tlsConfig); + self::assertSame('ca-cert-content', $profile->tlsConfig->rootCerts); + self::assertSame('client-cert-content', $profile->tlsConfig->certChain); + self::assertSame('client-key-content', $profile->tlsConfig->privateKey); + self::assertSame('custom-server', $profile->tlsConfig->serverName); + } + + public function testToTomlRoundTripWithGrpcMetadata(): void + { + // Arrange: Load profile with gRPC metadata + $originalToml = <<<'TOML' + [profile.with_meta] + address = "meta.example.com:7233" + namespace = "meta-namespace" + [profile.with_meta.grpc_meta] + authorization = "Bearer token123" + x-custom-header = "custom-value" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify gRPC metadata is preserved + $profile = $roundTrip->getProfile('with_meta'); + self::assertSame('meta.example.com:7233', $profile->address); + self::assertSame('meta-namespace', $profile->namespace); + self::assertArrayHasKey('authorization', $profile->grpcMeta); + self::assertSame(['Bearer token123'], $profile->grpcMeta['authorization']); + self::assertArrayHasKey('x-custom-header', $profile->grpcMeta); + self::assertSame(['custom-value'], $profile->grpcMeta['x-custom-header']); + } + + public function testToTomlRoundTripWithTlsDisabled(): void + { + // Arrange: Load profile with explicitly disabled TLS + $originalToml = <<<'TOML' + [profile.no_tls] + address = "localhost:7233" + namespace = "local" + [profile.no_tls.tls] + disabled = true + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify TLS remains disabled + $profile = $roundTrip->getProfile('no_tls'); + self::assertSame('localhost:7233', $profile->address); + self::assertTrue($profile->tlsConfig->disabled); + } + + public function testToTomlRoundTripWithApiKeyEnablesTls(): void + { + // Arrange: Load profile with API key (auto-enables TLS) + $originalToml = <<<'TOML' + [profile.cloud] + address = "cloud.example.com:7233" + namespace = "cloud-namespace" + api_key = "cloud-api-key" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify TLS is enabled and API key is preserved + $profile = $roundTrip->getProfile('cloud'); + self::assertSame('cloud.example.com:7233', $profile->address); + self::assertSame('cloud-namespace', $profile->namespace); + self::assertSame('cloud-api-key', $profile->apiKey); + self::assertFalse($profile->tlsConfig->disabled); + } + + public function testToTomlRoundTripPreservesCaseInsensitiveProfileNames(): void + { + // Arrange: Load profiles with various case names + $originalToml = <<<'TOML' + [profile.Default] + address = "default.example.com:7233" + + [profile.PRODUCTION] + address = "prod.example.com:7233" + + [profile.MixedCase] + address = "mixed.example.com:7233" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: All profiles accessible (case-insensitive) + self::assertCount(3, $roundTrip->profiles); + self::assertTrue($roundTrip->hasProfile('default')); + self::assertTrue($roundTrip->hasProfile('Default')); + self::assertTrue($roundTrip->hasProfile('production')); + self::assertTrue($roundTrip->hasProfile('PRODUCTION')); + self::assertTrue($roundTrip->hasProfile('mixedcase')); + self::assertTrue($roundTrip->hasProfile('MixedCase')); + } + + public function testToTomlRoundTripWithMinimalProfile(): void + { + // Arrange: Load minimal profile (just address) + $originalToml = <<<'TOML' + [profile.minimal] + address = "minimal.example.com:7233" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify minimal profile is preserved + $profile = $roundTrip->getProfile('minimal'); + self::assertSame('minimal.example.com:7233', $profile->address); + self::assertNull($profile->namespace); + self::assertNull($profile->apiKey); + self::assertTrue($profile->tlsConfig->disabled); + } + + public function testToTomlRoundTripWithEmptyGrpcMeta(): void + { + // Arrange: Load profile with empty grpc_meta section + $originalToml = <<<'TOML' + [profile.empty_meta] + address = "empty.example.com:7233" + [profile.empty_meta.grpc_meta] + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify empty metadata is preserved + $profile = $roundTrip->getProfile('empty_meta'); + self::assertSame('empty.example.com:7233', $profile->address); + self::assertSame([], $profile->grpcMeta); + } + + public function testToTomlRoundTripWithAllFeatures(): void + { + // Arrange: Load profile with all possible features + $originalToml = <<<'TOML' + [profile.full] + address = "full.example.com:7233" + namespace = "full-namespace" + api_key = "full-api-key" + [profile.full.tls] + server_ca_cert_data = "full-ca-data" + client_cert_data = "full-cert-data" + client_key_data = "full-key-data" + server_name = "full-server" + [profile.full.grpc_meta] + authorization = "Bearer full-token" + x-custom-1 = "value1" + x-custom-2 = "value2" + TOML; + $original = ConfigClient::loadFromToml($originalToml); + + // Act: Convert to TOML and reload + $tomlString = $original->toToml(); + $roundTrip = ConfigClient::loadFromToml($tomlString); + + // Assert: Verify all features are preserved + $profile = $roundTrip->getProfile('full'); + + // Basic fields + self::assertSame('full.example.com:7233', $profile->address); + self::assertSame('full-namespace', $profile->namespace); + self::assertSame('full-api-key', $profile->apiKey); + + // TLS config + self::assertFalse($profile->tlsConfig->disabled); + self::assertSame('full-ca-data', $profile->tlsConfig->rootCerts); + self::assertSame('full-cert-data', $profile->tlsConfig->certChain); + self::assertSame('full-key-data', $profile->tlsConfig->privateKey); + self::assertSame('full-server', $profile->tlsConfig->serverName); + + // gRPC metadata + self::assertCount(3, $profile->grpcMeta); + self::assertSame(['Bearer full-token'], $profile->grpcMeta['authorization']); + self::assertSame(['value1'], $profile->grpcMeta['x-custom-1']); + self::assertSame(['value2'], $profile->grpcMeta['x-custom-2']); + } + + protected function setUp(): void + { + // Arrange (common setup) + $this->envProvider = new ArrayEnvProvider(); + } +}