diff --git a/CHANGELOG.md b/CHANGELOG.md index a22ca2a..e3dd866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,42 +1,74 @@ # Changelog -## [3.0.0] — unreleased +## [3.2.0] — unreleased + +### Added +- `Auth` — credentials as a transport-independent value: `Auth::token()`, `Auth::oauth2()`, `Auth::basic()`. The constructor is private, so the set of valid schemes is closed by construction; empty tokens and usernames are rejected on creation instead of surfacing as a 401 later +- `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport +- `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request +- `ConnectionConfig::$allowInsecureHttp` / `RequestHandler`'s `allowInsecureHttp:` argument — opt out of the cleartext-credential check described below + +### Changed +- Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete +- **Breaking:** credentials are no longer sent in cleartext. Constructing a client with an `Auth` and a plain `http://` URL whose host is not loopback now throws an `InvalidArgumentException` instead of putting the token on the wire for anyone on the path to read. Loopback is exempt — `localhost`, any `.localhost` name, the whole `127.0.0.0/8` range and `::1` — so every default this project ships (`http://localhost:3000` in the cookbook, `http://127.0.0.1:8098` in both bridges) is unaffected, as is any `https://` URL. **Who is affected:** anyone pointing the client at a remote Zammad over plain HTTP, e.g. an app server talking to an internal instance or a container reaching `http://zammad-nginx:3000`. Switch the URL to `https://`, or, if the connection is already protected by other means (VPN, service mesh, SSH tunnel), opt out with `new ConnectionConfig(allowInsecureHttp: true)` — or `allowInsecureHttp: true` when building a `RequestHandler` directly. The check keys on the `Auth` it is given, so an `Authorization` header that your own PSR-18 client adds stays your responsibility ([#173](https://github.com/zammad/zammad-api-client-php/pull/173)) +- `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected +- The `@deprecated` marker on `DTOInterface::id()`'s trait implementation is withdrawn. It announced a removal in 4.0 that was never planned, was never mentioned in the 3.0.0 changelog that introduced it, and contradicted both the interface — which requires the method and is not deprecated — and the library's own use of it in `Resource`. Reading `$dto->id` directly remains the more readable form +- Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) +- **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler` +- **Fixed:** `SymfonyBundle` registered the client with `ContainerBuilder::set()`, which stores a live instance rather than a definition. Instances set that way do not survive container compilation, so any service injecting `ZammadClient` failed the build with *"has a dependency on a non-existent service"* — the bundle never worked in a compiled container. It now registers a lazy `Definition` with `ZammadClient::create()` as its factory and an inlined `Auth` definition, so nothing is instantiated at compile time and the credentials are resolved from `%env(ZAMMAD_TOKEN)%` at runtime instead of being written into `var/cache` +- **Fixed:** the password parameters of `Auth::basic()` and `GuzzleClientFactory::withBasicAuth()` are marked `#[\SensitiveParameter]`. An empty username throws from inside `Auth::basic()`, so with PHP's default `zend.exception_ignore_args=0` the password appeared verbatim in the stack trace. The attribute is honoured from PHP 8.2 on and inert on 8.1 +- **Fixed:** the Symfony section of the custom HTTP client guide offered the `create()` definition and a custom PSR-18 stack in one `services:` block, with the custom stack under its own `app.zammad_client` ID. Autowiring resolves a `ZammadClient` type-hint to the `ZammadAPIClient\ZammadClient` ID, so anyone copying that block got the Guzzle client and a dead custom stack. The two are now separate, clearly alternative blocks, both registering the class ID — the same shape `SymfonyBundle` uses +- **Fixed:** the Slim recipe (`examples/cookbook/10-slim.php`) told readers to create a `.env` file and then read `$_ENV` without loading one — and `$_ENV` is empty altogether unless `variables_order` includes `E`. It now uses `getenv()`, like every other recipe +- **Fixed:** the v3 migration guide's `composer require` line allowed `^3.0`, while the client instantiation shown directly below it uses `ZammadClient::create()`, which only exists from 3.2.0 +- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory`. The fallback for installations without Flex, in that same section, then declared the factory under its class name while the handler referenced `nyholm.psr7.psr17_factory` — reproducing the very *"has a dependency on a non-existent service"* build error it was meant to avoid. It now declares the ID the handler asks for + +`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code. + +**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable therefore fails earlier and more clearly than before. Both framework bridges resolve the client lazily, so this surfaces the first time something asks the container for it, not while the container is built: Laravel falls back to an empty token (`env('ZAMMAD_TOKEN', '')`) and throws `InvalidArgumentException`, while the Symfony bundle falls back to `%env(ZAMMAD_TOKEN)%` and throws `EnvNotFoundException` when the variable is unset, or `InvalidArgumentException` when it is set but empty. The Symfony bundle only became lazy in this release — see the fix above; before it, an empty token made `cache:clear` (and with it `composer install`) fail outright. The bundles are still covered by neither tests nor static analysis ([#169](https://github.com/zammad/zammad-api-client-php/issues/169)); the bundle change above was verified by compiling and dumping a container against symfony/dependency-injection 7.4 outside this repository. + +## [3.1.0] — 2026-10-02 + +### Added +- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids`, `group_ids` and `default_at_signup` are writable on create and update ([#167](https://github.com/zammad/zammad-api-client-php/pull/167)) + +### Changed +- Documentation: README restructured for newcomers (install / Packagist section), cookbook setup instructions and a broken personal access token link fixed + +## [3.0.0] — 2026-09-11 ### Added -- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids` and `group_ids` are writable on create and update - PSR-18 / PSR-17 compliant HTTP layer (`RequestHandler`, `RetryAfterMiddleware`) -- Typed DTOs for all 11 resources (`Ticket`, `User`, `Organization`, `Group`, `Role`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) +- Typed DTOs for all 10 resources (`Ticket`, `User`, `Organization`, `Group`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) - Repository pattern with generator-based pagination (`AbstractRepository`) - `patch()` method for partial updates via `array` or `TicketUpdateDTO` -- Proper exception hierarchy: `AuthenticationException`, `NotFoundException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` -- `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods +- Proper exception hierarchy: `AuthenticationException`, `ForbiddenException`, `NotFoundException`, `BadRequestException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` +- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods (namespace `ZammadAPIClient\Factory`) - PSR-3 Logger injection - OpenAPI schema validation in integration tests - Framework bridges for Laravel and Symfony - `TicketArticleType` enum for article channel types -- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` baut den `RequestHandler` -- `ImpersonationHandler` — stateless decorator fur API-Impersonation -- `ImpersonationHandler` — scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` +- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` builds the `RequestHandler` +- `ImpersonationHandler` — stateless decorator for API impersonation, scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` ### Changed - **Breaking:** PHP >= 8.1 required -- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (Namespace `ZammadAPIClient\Factory`). +- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (namespace `ZammadAPIClient\Factory`). Non-Guzzle via `new ZammadClient(new RequestHandler($psr18Client, $psr17Factory, $url))`. -- **Breaking:** `ZammadClient::withClient()` entfernt; `ZammadClient` constructor akzeptiert `RequestHandlerInterface|ClientFactoryInterface` -- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` entfernt. +- **Breaking:** `ZammadClient::withClient()` removed; the `ZammadClient` constructor accepts `RequestHandlerInterface|ClientFactoryInterface` +- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` removed. Impersonation via `new ZammadClient(new ImpersonationHandler($handler, $userId))`. -- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` entfernt. - `RequestHandler` halt keinen Impersonation-State mehr. -- **Breaking:** `RequestHandlerInterface::getRaw()` Signatur um `$headers`-Parameter erweitert +- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` removed. + `RequestHandler` no longer holds any impersonation state. +- **Breaking:** `RequestHandlerInterface::getRaw()` signature extended by a `$headers` parameter - **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors - **Breaking:** Array return values replaced by typed DTOs -- **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client -- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` +- **Breaking:** Guzzle used as default transport; any PSR-18 client can be injected via `RequestHandler` +- `getListKey()` in repository classes is no longer abstract; it defaults to `$this->resourcePath` - Typed repository accessors (`$client->ticket()`, `$client->user()`, …) are the recommended public API; the underlying `repo()` method is `@internal`. ### Removed -- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt -- Shared mutable Impersonation-State aus `RequestHandler` entfernt +- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, now removed +- Shared mutable impersonation state in `RequestHandler` --- diff --git a/README.md b/README.md index 595a6b3..5cfad2f 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ resolve `ZammadClient` from the container instead of constructing it by hand. ```php require __DIR__ . '/vendor/autoload.php'; // skip if your framework autoloads +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` **3. Try it** — fetch, create, update and search tickets: @@ -86,10 +87,11 @@ Repositories are accessed via typed accessors: `$client->ticket()`, `$client->us ### Connecting ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -// ZammadClient normalizes the URL — /api/v1 is appended automatically -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +// The URL is normalized — /api/v1 is appended automatically +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` ### Fetching @@ -350,7 +352,7 @@ CSV format follows Zammad's import specification (header row with field names ma The primary example is the [`examples/cookbook/`](examples/cookbook/README.md) directory — runnable recipes covering tickets, stateful resources, pagination, error handling, impersonation, and search. Run them against any Zammad instance: ```bash -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://your-zammad:3000 \ +ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=https://your-zammad.example \ ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=your-token \ php examples/cookbook/01-quick-start.php ``` @@ -361,27 +363,55 @@ For user and organization CRUD examples, refer to the integration tests in [`tes ## Authentication +Credentials are a value of their own. `Auth` has a private constructor, so the +only way to build one is through the three named constructors below — an empty +token or username is rejected right there instead of surfacing as a 401 later. + ```php +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; +use ZammadAPIClient\ZammadClient; + // Token — sends Authorization: Token token=your-token (Zammad personal access token) -ZammadClient::withToken($url, 'your-token'); +$client = ZammadClient::create($url, Auth::token('your-token')); // OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token) -ZammadClient::withOAuth2($url, 'your-oauth-token'); +$client = ZammadClient::create($url, Auth::oauth2('your-oauth-token')); // Basic Auth — sends Authorization: Basic base64(user:pass) -ZammadClient::withBasicAuth($url, 'admin@example.com', 'test'); +$client = ZammadClient::create($url, Auth::basic('admin@example.com', 'test')); // Options -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(verifySsl: false, maxRetries: 5), ); // Pass a PSR-3 Logger to log HTTP requests and retries -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(logger: $myLogger), ); ``` +The client refuses to send a credential in cleartext: a `http://` URL pointing at +anything but the local machine throws an `InvalidArgumentException` instead of +leaking the token to the network. Loopback is exempt, so the local defaults keep +working. If the connection is protected by other means — a VPN, a service mesh, a +tunnel — opt out with `new ConnectionConfig(allowInsecureHttp: true)`. + +The credential is applied per request by the transport layer, so it works the +same with any PSR-18 client. Guzzle is only the default — to use another one, +build the handler yourself: + +```php +use ZammadAPIClient\Core\Transport\RequestHandler; + +$handler = new RequestHandler($psr18Client, $psr17Factory, $url, auth: Auth::token('your-token')); +$client = new ZammadClient($handler); +``` + +Runnable example: [`examples/cookbook/07-custom-http-client.php`](examples/cookbook/07-custom-http-client.php). +Container wiring and security notes: [Custom HTTP client](docs/custom-http-client.md). + | ConnectionConfig property | Type | Default | Description | |---------------------------|------|---------|-------------| | `maxRetries` | `int` | `3` | Number of retries on HTTP 429 before throwing `RateLimitException` | @@ -389,6 +419,7 @@ ZammadClient::withToken($url, 'your-token', | `timeout` | `int` | `30` | Total request timeout in seconds | | `connectTimeout` | `int` | `10` | Connection timeout in seconds | | `logger` | `?LoggerInterface` | `null` | PSR-3 Logger for HTTP request/retry logging | +| `allowInsecureHttp` | `bool` | `false` | Permit credentials over plain `http://` to a non-loopback host | ## Framework integration @@ -396,8 +427,8 @@ Prefer to resolve `ZammadClient` from your framework's container instead of constructing it by hand? Bridges are provided for Laravel and Symfony — configure credentials once, then inject the same shared client everywhere. -Full runnable setups: [`examples/cookbook/07-laravel.php`](examples/cookbook/07-laravel.php) -and [`examples/cookbook/08-symfony.php`](examples/cookbook/08-symfony.php). +Full runnable setups: [`examples/cookbook/08-laravel.php`](examples/cookbook/08-laravel.php) +and [`examples/cookbook/09-symfony.php`](examples/cookbook/09-symfony.php). ### Laravel @@ -705,18 +736,31 @@ $client->ticket()->patch(42, new TicketUpdateDTO( ## Impersonation +Impersonation is scoped to its own client instance — wrap the handler in an +`ImpersonationHandler`. It decorates the handler you hand it, so the credentials +of the original client carry over; the only thing it adds is the `From` header. +That original client keeps acting as the authenticated user, so there is no state +to reset and nothing can leak between calls. + ```php -// Temporary — auto-cleanup via finally -// Accepts user ID (int), login, or email (string) -$client->performOnBehalfOf(1, fn() => $client->ticket()->find(42)); -$client->performOnBehalfOf('agent@example.com', fn() => $client->ticket()->find(42)); - -// Persistent — same parameter types -$client->setOnBehalfOfUser(1); -// ... all subsequent requests act as user #1 ... -$client->unsetOnBehalfOfUser(); +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\ImpersonationHandler; +use ZammadAPIClient\ZammadClient; + +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); + +// Accepts user ID (int), login, or email (string); no credentials needed here — +// they come from $client's handler +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$ticket = $asUser->ticket()->find(42); + +$asAgent = new ZammadClient(new ImpersonationHandler($client->getHandler(), 'agent@example.com')); + +// $client itself is unaffected ``` +A runnable recipe is in [`examples/cookbook/05-impersonation.php`](examples/cookbook/05-impersonation.php). + ## Development ```bash @@ -752,7 +796,7 @@ v2 reference documentation is preserved in [docs/v2-reference.md](docs/v2-refere | v2 | v3 | |----|----| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, ...)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token(...))` | | `$client->resource(TICKET)->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | | `$ticket->getValues()` | `$ticket->toArray()` | diff --git a/docs/alternative-clients.md b/docs/alternative-clients.md deleted file mode 100644 index 186ddfc..0000000 --- a/docs/alternative-clients.md +++ /dev/null @@ -1,144 +0,0 @@ -# Using an Alternative PSR-18 HTTP Client - -The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. The factory methods (`withToken()`, `withOAuth2()`, `withBasicAuth()`) use Guzzle by default for convenience, but you can inject any PSR-18 client directly. - -Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. - -## Symfony HttpClient - -Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7` for PSR-17 factories). - -```php -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; -use ZammadAPIClient\ZammadClient; - -$handler = new RequestHandler( - new Psr18Client(HttpClient::create([ - 'timeout' => 30, - 'max_duration' => 30, - 'verify_peer' => true, - 'verify_host' => true, - ])), - new HttpFactory(), - 'https://zammad.example', - maxRetries: 3, -); - -$client = new ZammadClient($handler); -``` - -### With Authentication Headers - -Symfony's `Psr18Client` does not accept global default headers. Instead, configure them on the `RequestHandler` by passing them via `$options` on each `request()` call, or use a PSR-18 middleware to inject the `Authorization` header. - -**Option A — Inject headers via a PSR-18 decorator middleware:** - -```php -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; - -final class AuthMiddleware implements ClientInterface -{ - public function __construct( - private ClientInterface $next, - private string $token, - ) {} - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->next->sendRequest( - $request->withHeader('Authorization', "Token token={$this->token}") - ); - } -} - -$http = new AuthMiddleware( - new Psr18Client(HttpClient::create(['timeout' => 30])), - 'your-zammad-api-token', -); - -// Rate-limit retry is handled automatically by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example'); -``` - -**Option B — Build the Guzzle client with auth headers, then swap to Symfony later:** - -If you're migrating away from Guzzle, start with `ZammadClient::withToken()` and refactor step by step. - -## Laravel - -The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony instead, override the binding in your `AppServiceProvider`: - -```php -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; -use ZammadAPIClient\ZammadClient; - -class AppServiceProvider extends ServiceProvider -{ - public function register(): void - { - $this->app->singleton(ZammadClient::class, function () { - $handler = new RequestHandler( - new Psr18Client(HttpClient::create([ - 'timeout' => config('zammad.timeout', 30), - ])), - new HttpFactory(), - config('zammad.url'), - maxRetries: 3, - ); - return new ZammadClient($handler); - }); - } -} -``` - -## Symfony - -The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition: - -```yaml -# config/services.yaml -services: - ZammadAPIClient\ZammadClient: - factory: ['ZammadAPIClient\ZammadClient', 'withToken'] - arguments: - $url: '%env(ZAMMAD_URL)%' - $token: '%env(ZAMMAD_TOKEN)%' - - # Or with a custom PSR-18 stack: - app.zammad_client: - class: ZammadAPIClient\ZammadClient - arguments: - - '@app.zammad_request_handler' -``` - -## Custom PSR-18 Middleware Stack - -`RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). - -```php -$http = new LoggingMiddleware( - new MetricsMiddleware( - new Psr18Client(), - ), -); - -// Rate-limit retry is applied internally by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example', maxRetries: 5); -``` - -## Important Security Notes - -- **Configure timeouts**: Always set `timeout` and connection timeout on your PSR-18 client. The factory methods default to 30s/10s respectively. -- **Verify TLS**: Do not disable TLS verification in production. The factory methods default to `verifySsl = true`. -- **Redirects**: The factory methods disable redirect following (`allow_redirects = false`). If using your own PSR-18 client, configure redirect behavior carefully — following redirects may leak the `Authorization` header to unintended hosts. -- **Debug logging**: Use a PSR-3 logger via `ConnectionConfig`. Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md new file mode 100644 index 0000000..2f015ed --- /dev/null +++ b/docs/custom-http-client.md @@ -0,0 +1,195 @@ +# Using a Custom HTTP Client + +The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. `ZammadClient::create()` uses Guzzle for convenience, but nothing is lost by building the handler yourself: credentials, retry behaviour and logging are configured the same way either side. + +Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. + +A runnable version of the setup below is in [`examples/cookbook/07-custom-http-client.php`](../examples/cookbook/07-custom-http-client.php). + +## Symfony HttpClient + +Requires `composer require symfony/http-client nyholm/psr7`. Any PSR-17 factory works; `guzzlehttp/psr7` is already installed as a dependency if you prefer it. + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Nyholm\Psr7\Factory\Psr17Factory; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; +use ZammadAPIClient\ZammadClient; + +$handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => 30, + 'max_duration' => 30, + 'verify_peer' => true, + 'verify_host' => true, + ])), + new Psr17Factory(), + 'https://zammad.example', + maxRetries: 3, + auth: Auth::token('your-zammad-api-token'), +); + +$client = new ZammadClient($handler); +``` + +### Authentication + +Pass an `Auth` to the `RequestHandler`, as above. It applies the `Authorization` +header to each outgoing request, so it does not rely on the PSR-18 client +supporting client-level default headers — PSR-18 has no such concept, and +Symfony's `Psr18Client` does not offer one. + +`Auth::token()`, `Auth::oauth2()` and `Auth::basic()` are the available schemes. +If you need credentials that change at runtime (an OAuth2 token being refreshed, +say), wrap your PSR-18 client in a decorator that sets the header per request and +leave `auth` unset. + +Constructing a `RequestHandler` with an `Auth` and a plain `http://` base URL +throws an `InvalidArgumentException`, because the credential would travel the +network in cleartext. Loopback hosts are exempt; for anything else that is +already protected by other means — a VPN, a service mesh, an SSH tunnel — pass +`allowInsecureHttp: true`. Note that the check sees only the `Auth` you hand it: +a decorator that sets the header itself, as described above, is invisible to it +and remains your responsibility. + +If you're migrating away from Guzzle, start with `ZammadClient::create()` and +refactor step by step — the handler built above supports exactly the same options. + +## Laravel + +The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony instead, override the binding in your `AppServiceProvider`: + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Nyholm\Psr7\Factory\Psr17Factory; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; +use ZammadAPIClient\ZammadClient; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app->singleton(ZammadClient::class, function () { + $handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => config('zammad.timeout', 30), + ])), + new Psr17Factory(), + config('zammad.url'), + maxRetries: 3, + auth: Auth::token(config('zammad.token')), + ); + return new ZammadClient($handler); + }); + } +} +``` + +## Symfony + +The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition. + +Both variants below register the client under the service ID `ZammadAPIClient\ZammadClient`, +because that is the ID Symfony resolves when a service type-hints `ZammadClient`. Pick +**one** of them, and remove `SymfonyBundle` from `config/bundles.php` — the bundle +registers the very same ID, so leaving it enabled means one definition silently wins over +the other. + +Either the default (Guzzle) transport: + +```yaml +# config/services.yaml +services: + app.zammad_auth: + class: ZammadAPIClient\Core\Auth + factory: ['ZammadAPIClient\Core\Auth', 'token'] + arguments: + $token: '%env(ZAMMAD_TOKEN)%' + + ZammadAPIClient\ZammadClient: + factory: ['ZammadAPIClient\ZammadClient', 'create'] + arguments: + $url: '%env(ZAMMAD_URL)%' + $auth: '@app.zammad_auth' +``` + +…or your own PSR-18 stack. Note that the class service ID is the one that gets the custom +handler — a separate `app.zammad_client` ID would be ignored by autowiring, and every +service type-hinting `ZammadClient` would silently keep using the Guzzle definition above: + +```yaml +# config/services.yaml +services: + app.zammad_auth: + class: ZammadAPIClient\Core\Auth + factory: ['ZammadAPIClient\Core\Auth', 'token'] + arguments: + $token: '%env(ZAMMAD_TOKEN)%' + + app.zammad_request_handler: + class: ZammadAPIClient\Core\Transport\RequestHandler + arguments: + $httpClient: '@Psr\Http\Client\ClientInterface' + $factory: '@nyholm.psr7.psr17_factory' + $baseUrl: '%env(ZAMMAD_URL)%' + $auth: '@app.zammad_auth' + + ZammadAPIClient\ZammadClient: + arguments: + - '@app.zammad_request_handler' +``` + +The two service IDs in the custom stack are the ones Symfony actually registers: +FrameworkBundle aliases `Psr\Http\Client\ClientInterface` to its `psr18.http_client` +service as soon as `symfony/http-client` is installed, and `nyholm.psr7.psr17_factory` +comes from the `nyholm/psr7` Flex recipe. The class names are *not* service IDs — the +package itself ships no DI configuration. If you installed it without Flex, declare the +factory under the ID referenced above — the reference is explicit, so autowiring will not +bridge a differently named service: + +```yaml + nyholm.psr7.psr17_factory: + class: Nyholm\Psr7\Factory\Psr17Factory +``` + +## Custom PSR-18 Middleware Stack + +`RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). + +`LoggingMiddleware` and `MetricsMiddleware` below stand in for your own decorators. + +```php +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; + +$http = new LoggingMiddleware( + new MetricsMiddleware( + new Psr18Client(), + ), +); + +// Rate-limit retry is applied internally by RequestHandler +$handler = new RequestHandler( + $http, + new Psr17Factory(), + 'https://zammad.example', + maxRetries: 5, + auth: Auth::token('your-zammad-api-token'), +); +``` + +## Important Security Notes + +Building the handler yourself means the defaults `ZammadClient::create()` applies are no longer applied for you. Each of these is now your responsibility: + +- **Configure timeouts**: Always set a request and a connection timeout on your PSR-18 client. `ZammadClient::create()` defaults to 30s and 10s (`ConnectionConfig::$timeout` / `$connectTimeout`). +- **Verify TLS**: Do not disable TLS verification in production. `ZammadClient::create()` defaults to `verifySsl = true`. +- **Redirects**: `ZammadClient::create()` disables redirect following (`allow_redirects = false`). Configure your own client carefully — following redirects may leak the `Authorization` header to unintended hosts. +- **Plain HTTP**: `RequestHandler` refuses an `Auth` over `http://` to a non-loopback host, and `allowInsecureHttp: true` turns that off. The check cannot see an `Authorization` header your own PSR-18 decorator adds — if you authenticate that way, make sure the URL is `https://` yourself. +- **Debug logging**: Pass a PSR-3 logger as the `logger:` argument of `RequestHandler` (on the `create()` path it comes from `ConnectionConfig::$logger`). Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/migration-v3-examples.md b/docs/migration-v3-examples.md index 02d55ef..505ba5a 100644 --- a/docs/migration-v3-examples.md +++ b/docs/migration-v3-examples.md @@ -14,11 +14,12 @@ $client = new Client([ **v3:** ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken( +$client = ZammadClient::create( 'https://zammad.example', - 'your-token', + Auth::token('your-token'), ); ``` @@ -35,9 +36,9 @@ new Client(['url' => $url, 'username' => '...', 'password' => '...']); // Basic **v3:** ```php -ZammadClient::withToken($url, '...'); // Token -ZammadClient::withOAuth2($url, '...'); // OAuth2 -ZammadClient::withBasicAuth($url, '...', '...'); // Basic +ZammadClient::create($url, Auth::token('...')); // Token +ZammadClient::create($url, Auth::oauth2('...')); // OAuth2 +ZammadClient::create($url, Auth::basic('...', '...')); // Basic ``` --- @@ -342,18 +343,17 @@ $client->unsetOnBehalfOfUser(); **v3:** ```php -// Temporary (auto-cleanup on callback return or exception) -$client->performOnBehalfOf(1, function () use ($client) { - $client->ticket()->find(42); -}); +use ZammadAPIClient\Core\Transport\ImpersonationHandler; -// Persistent -$client->setOnBehalfOfUser(1); -// ... API calls ... -$client->unsetOnBehalfOfUser(); +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$asUser->ticket()->find(42); + +// $client itself keeps acting as the authenticated user ``` -> **Breaking:** v2 used a username string (`'myuser'`). v3 requires a **numeric user ID**. Use `$client->user()->search('login:myuser')` to resolve a username to an ID if needed. +> **Breaking:** v2 toggled impersonation on the client itself, so the state was global and had to be reset. v3 scopes it to a separate client instance — there is nothing to unset and nothing can leak between calls. A user ID, login or email all work (`int|string`). +> +> No credentials are passed to `ImpersonationHandler`: it decorates `$client`'s existing handler and inherits its authentication, adding only the `From` header. --- diff --git a/docs/migration-v3.md b/docs/migration-v3.md index 484d0fa..5faec0e 100644 --- a/docs/migration-v3.md +++ b/docs/migration-v3.md @@ -4,7 +4,7 @@ | v2 | v3 | |---|---| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, $token)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token($token))` | | `$client->resource(TICKET)` | `$client->ticket()` | | `$ticket->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | @@ -28,7 +28,7 @@ ### 1. Update composer.json ```bash -composer require zammad/zammad-api-client-php:^3.0 +composer require zammad/zammad-api-client-php:^3.2 ``` ### 2. Replace Client Instantiation @@ -41,9 +41,9 @@ $client = new \ZammadAPIClient\Client([ ]); // v3 -$client = \ZammadAPIClient\ZammadClient::withToken( +$client = \ZammadAPIClient\ZammadClient::create( 'https://zammad.example', - 'your-token' + \ZammadAPIClient\Core\Auth::token('your-token') ); ``` diff --git a/examples/cookbook/00-plain.php b/examples/cookbook/00-connect.php similarity index 52% rename from examples/cookbook/00-plain.php rename to examples/cookbook/00-connect.php index eb7a6cc..96e395e 100644 --- a/examples/cookbook/00-plain.php +++ b/examples/cookbook/00-connect.php @@ -1,17 +1,21 @@ ['User-Agent' => 'Zammad API PHP'], + 'timeout' => 30, + ]), +); + +$handler = new RequestHandler( + $httpClient, + new Psr17Factory(), + $url, + maxRetries: 3, + auth: Auth::token($token), +); + +$client = new ZammadClient($handler); + +echo "Connected to {$url} (Symfony HttpClient)\n"; +echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/07-laravel.php b/examples/cookbook/08-laravel.php similarity index 100% rename from examples/cookbook/07-laravel.php rename to examples/cookbook/08-laravel.php diff --git a/examples/cookbook/09-slim.php b/examples/cookbook/09-slim.php deleted file mode 100644 index 4baae3a..0000000 --- a/examples/cookbook/09-slim.php +++ /dev/null @@ -1,44 +0,0 @@ -create([ - 'headers' => [ - 'User-Agent' => 'Zammad API PHP', - 'Authorization' => "Token token={$token}", - ], - ]), -); - -$psr17 = new Psr17Factory(); - -$handler = new RequestHandler($symfonyClient, $psr17, $url); -$client = new ZammadClient($handler); - -echo "Connected to {$url} (Symfony HttpClient)\n"; -echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/08-symfony.php b/examples/cookbook/09-symfony.php similarity index 100% rename from examples/cookbook/08-symfony.php rename to examples/cookbook/09-symfony.php diff --git a/examples/cookbook/10-slim.php b/examples/cookbook/10-slim.php new file mode 100644 index 0000000..3c1e470 --- /dev/null +++ b/examples/cookbook/10-slim.php @@ -0,0 +1,85 @@ +addDefinitions([ + * ZammadClient::class => fn (): ZammadClient => ZammadClient::create( + * getenv('ZAMMAD_URL') ?: 'http://localhost:3000', + * Auth::token(getenv('ZAMMAD_TOKEN') ?: ''), + * ), + * ]); + * + * AppFactory::setContainer($builder->build()); + * $app = AppFactory::create(); + * $app->addErrorMiddleware(true, true, true); + * + * $app->get('/tickets/{id}', ShowTicket::class); + * $app->run(); + * + * The action gets the client injected — PHP-DI autowires the constructor, so + * nothing else has to know where the client comes from: + * + * use Psr\Http\Message\ResponseInterface; + * use Psr\Http\Message\ServerRequestInterface; + * use ZammadAPIClient\Exceptions\NotFoundException; + * use ZammadAPIClient\ZammadClient; + * + * final class ShowTicket + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function __invoke( + * ServerRequestInterface $request, + * ResponseInterface $response, + * array $args, + * ): ResponseInterface { + * try { + * $ticket = $this->zammad->ticket()->find((int) $args['id']); + * } catch (NotFoundException) { + * return $response->withStatus(404); + * } + * + * $response->getBody()->write((string) json_encode($ticket->toArray())); + * + * return $response->withHeader('Content-Type', 'application/json'); + * } + * } + * + * Definitions are lazy: the client is built the first time something asks for + * it, not on every request. Because Auth rejects an empty token, a missing + * ZAMMAD_TOKEN surfaces as an InvalidArgumentException at that point rather + * than as a confusing 401 later. + * + * To use a different HTTP client here, return a client built from your own + * RequestHandler instead — see 07-custom-http-client.php. + */ + +declare(strict_types=1); + +// Wiring happens in your Slim application — this file is for reference. +fwrite(STDOUT, "See docblock above for Slim 4 setup instructions.\n"); diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 49ea9e2..52a00e9 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -12,18 +12,31 @@ export ZAMMAD_TOKEN=your-token ## Recipes +**Getting started** + | File | Description | |---|---| -| [`00-plain.php`](00-plain.php) | Guzzle setup (used by recipes 01-06). Standalone, copy-paste-ready. | +| [`00-connect.php`](00-connect.php) | Connect to Zammad. Standalone, copy-paste-ready; recipes 01-06 repeat this block. | | [`01-quick-start.php`](01-quick-start.php) | Client instantiation + find ticket #1. | | [`02-crud.php`](02-crud.php) | Create, read, and delete tickets. | | [`03-listing.php`](03-listing.php) | `all()` streaming, `list()` pagination, `totalCount()`. | | [`04-updates.php`](04-updates.php) | `patch()` partial update + `TicketUpdateDTO`. | | [`05-impersonation.php`](05-impersonation.php) | `ImpersonationHandler` for scoped on-behalf-of requests. | | [`06-search.php`](06-search.php) | Full-text search via `search()` and `searchList()`. | -| [`07-laravel.php`](07-laravel.php) | Laravel service container setup. | -| [`08-symfony.php`](08-symfony.php) | Symfony bundle setup. | -| [`09-slim.php`](09-slim.php) | Non-Guzzle setup (Symfony HttpClient + Nyholm PSR-17). | + +**Custom HTTP client** + +| File | Description | +|---|---| +| [`07-custom-http-client.php`](07-custom-http-client.php) | Use your own PSR-18 client instead of Guzzle (example: Symfony HttpClient + Nyholm PSR-17). See [docs/custom-http-client.md](../../docs/custom-http-client.md). | + +**Framework integration** + +| File | Description | +|---|---| +| [`08-laravel.php`](08-laravel.php) | Laravel service container setup. | +| [`09-symfony.php`](09-symfony.php) | Symfony bundle setup. | +| [`10-slim.php`](10-slim.php) | Slim 4 / PHP-DI setup. No bridge needed — the pattern for any PSR-11 container. | ## Run diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 90f3c0e..e986d09 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -5,7 +5,7 @@ namespace ZammadAPIClient\Bridge; use Illuminate\Support\ServiceProvider; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -69,9 +69,13 @@ * * 1. `config/zammad.php` values (after `vendor:publish`) * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) - * 3. Built-in defaults (`http://127.0.0.1:8098`, empty token) + * 3. Built-in default URL (`http://127.0.0.1:8098`) * - * @see ZammadClient::withToken() + * A token is required: there is no usable default for it. If neither + * `config('zammad.token')` nor `ZAMMAD_TOKEN` is set, resolving + * {@see ZammadClient} from the container throws `InvalidArgumentException`. + * + * @see ZammadClient::create() */ final class LaravelServiceProvider extends ServiceProvider { @@ -88,9 +92,7 @@ public function register(): void $url = config('zammad.url') ?: env('ZAMMAD_URL', 'http://127.0.0.1:8098'); $token = config('zammad.token') ?: env('ZAMMAD_TOKEN', ''); - return new ZammadClient( - GuzzleClientFactory::withToken($url, $token), - ); + return ZammadClient::create($url, Auth::token($token)); }); } } diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 8f4a138..545e92d 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -5,9 +5,10 @@ namespace ZammadAPIClient\Bridge; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -66,7 +67,7 @@ * // $container->get('zammad_client')->ticket()->all(); * ``` * - * @see ZammadClient::withToken() + * @see ZammadClient::create() */ final class SymfonyBundle extends Bundle { @@ -85,15 +86,24 @@ public function load(array $configs, ContainerBuilder $container): void $resolved = array_merge($resolved, $config); } - $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098'); - $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); + $container->setParameter('zammad.default_url', 'http://127.0.0.1:8098'); - $client = new ZammadClient( - GuzzleClientFactory::withToken($url, $token), + $url = $resolved['url'] ?? '%env(default:zammad.default_url:ZAMMAD_URL)%'; + $token = $resolved['token'] ?? '%env(ZAMMAD_TOKEN)%'; + + $auth = (new Definition(Auth::class)) + ->setFactory([Auth::class, 'token']) + ->setArguments([$token]); + + $container->setDefinition( + ZammadClient::class, + (new Definition(ZammadClient::class)) + ->setFactory([ZammadClient::class, 'create']) + ->setArguments([$url, $auth]) + ->setPublic(true), ); - $container->set(ZammadClient::class, $client); - $container->setAlias('zammad_client', ZammadClient::class); - $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); + + $container->setAlias('zammad_client', ZammadClient::class)->setPublic(true); } public function getNamespace(): string diff --git a/src/Core/Auth.php b/src/Core/Auth.php new file mode 100644 index 0000000..cede846 --- /dev/null +++ b/src/Core/Auth.php @@ -0,0 +1,105 @@ +`. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function token(string $token): self + { + return new self('Token token=' . self::requireNonEmpty($token, 'API token')); + } + + /** + * OAuth2 access token — sends `Authorization: Bearer `. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function oauth2(string $token): self + { + return new self('Bearer ' . self::requireNonEmpty($token, 'OAuth2 token')); + } + + /** + * HTTP basic auth — sends `Authorization: Basic base64(user:password)`. + * + * An empty password is permitted (RFC 7617); the username is required. + * + * `$password` is marked `#[\SensitiveParameter]` so that it is redacted from + * stack traces: an empty username throws from this very frame, and PHP's + * default `zend.exception_ignore_args=0` would otherwise print the password + * as a literal argument. The attribute is inert on PHP 8.1. + * + * @throws InvalidArgumentException If $user is empty. + */ + public static function basic(string $user, #[\SensitiveParameter] string $password): self + { + return new self( + 'Basic ' . base64_encode(self::requireNonEmpty($user, 'Username') . ':' . $password), + ); + } + + /** + * Returns the ready-to-send `Authorization` header value. + * + * @internal Consumed by the transport layer; not part of the public API. + */ + public function header(): string + { + return $this->header; + } + + /** + * Keeps the credential out of var_dump(), print_r() and stack traces. + * + * @return array + */ + public function __debugInfo(): array + { + return ['header' => '***redacted***']; + } + + /** + * Returns the value with surrounding whitespace removed. + * + * Trimming is not cosmetic. A credential read from a file or an environment + * variable commonly carries a trailing newline, which PSR-7 rejects as an + * invalid header value — the error then points at the transport rather than + * at the token. Leading whitespace is worse: it survives into the header and + * the request simply comes back 401. + */ + private static function requireNonEmpty(string $value, string $label): string + { + $trimmed = trim($value); + + return $trimmed === '' + ? throw new InvalidArgumentException("{$label} must not be empty.") + : $trimmed; + } +} diff --git a/src/Core/ConnectionConfig.php b/src/Core/ConnectionConfig.php index 36309e8..8feef58 100644 --- a/src/Core/ConnectionConfig.php +++ b/src/Core/ConnectionConfig.php @@ -8,12 +8,17 @@ final class ConnectionConfig { + /** + * @param bool $allowInsecureHttp Permit credentials over plain `http://` to a non-loopback host. + * Off by default; see {@see Transport\RequestHandler}. + */ public function __construct( public readonly int $maxRetries = 3, public readonly bool $verifySsl = true, public readonly int $timeout = 30, public readonly int $connectTimeout = 10, public readonly ?LoggerInterface $logger = null, + public readonly bool $allowInsecureHttp = false, ) { } } diff --git a/src/Core/Traits/SerializesToArray.php b/src/Core/Traits/SerializesToArray.php index a4534dc..8bd9b10 100644 --- a/src/Core/Traits/SerializesToArray.php +++ b/src/Core/Traits/SerializesToArray.php @@ -105,8 +105,9 @@ public function toArray(): array * The consuming class must declare `public readonly ?int $id = null`. * This method reads it directly; no property access indirection is used. * - * @deprecated Use the `$dto->id` property directly. The method will be - * removed in v4.0 alongside the DTOInterface contract change. + * Required by {@see \ZammadAPIClient\Core\Contracts\DTOInterface} and used + * internally by {@see \ZammadAPIClient\Core\Repository\Resource}. Reading + * `$dto->id` directly is equivalent and reads better in application code. */ public function id(): ?int { diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index 65a4b28..0c40c04 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -12,6 +12,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -34,6 +35,8 @@ * - Maps HTTP error codes to typed domain exceptions before returning * (see {@see self::dispatch()} and the private `mapError`/`validationError` methods). * - Stores the last PSR-7 response so callers can inspect headers afterward. + * - Applies the configured {@see Auth} credentials, and refuses to do so over an + * unencrypted connection to a non-loopback host. * * HTTP 429 rate-limit retry is handled transparently — no manual wiring needed. */ @@ -46,6 +49,7 @@ final class RequestHandler implements RequestHandlerInterface private StreamFactoryInterface $streamFactory; private string $baseUrl; private LoggerInterface $logger; + private ?Auth $auth; private ?ResponseInterface $lastResponse = null; /** @@ -54,6 +58,15 @@ final class RequestHandler implements RequestHandlerInterface * @param string $baseUrl Base URL; the API prefix (`/api/v1`) is appended if missing. * @param LoggerInterface $logger PSR-3 logger; defaults to NullLogger. * @param int $maxRetries Max retries on HTTP 429 (0 = disable). + * @param Auth|null $auth Credentials; sent as `Authorization` on every request. + * Null leaves the header unset, e.g. when the PSR-18 client + * already adds it. + * @param bool $allowInsecureHttp Permit credentials over plain `http://` to a + * non-loopback host; see + * {@see self::assertCredentialsAreNotSentInClear()}. + * + * @throws InvalidArgumentException If $factory is not also a StreamFactoryInterface, or if $auth would + * be sent in cleartext (see $allowInsecureHttp). */ public function __construct( ClientInterface $httpClient, @@ -61,6 +74,8 @@ public function __construct( string $baseUrl, LoggerInterface $logger = new NullLogger(), int $maxRetries = 3, + ?Auth $auth = null, + bool $allowInsecureHttp = false, ) { if (!$factory instanceof StreamFactoryInterface) { throw new InvalidArgumentException( @@ -74,6 +89,81 @@ public function __construct( $this->streamFactory = $factory; $this->baseUrl = self::normalizeBaseUrl($baseUrl); $this->logger = $logger; + $this->auth = $auth; + + if (!$allowInsecureHttp) { + self::assertCredentialsAreNotSentInClear($this->baseUrl, $auth); + } + } + + /** + * Refuses to build a handler that would send credentials over an unencrypted + * connection to a host other than the local machine. + * + * `dispatch()` attaches the `Authorization` header to every request, and + * $baseUrl is fixed for the lifetime of the handler — so checking once here + * covers every request this handler will ever make. + * + * Loopback is exempt: a token sent to 127.0.0.1 never reaches a network. That + * covers the defaults shipped with this library and the usual local tunnel in + * front of a remote instance. For everything else, use `https://`, or pass + * `allowInsecureHttp: true` when the transport is already protected by other + * means (a VPN, a service mesh, an SSH tunnel on a non-loopback interface). + * + * The check keys on $auth, so it cannot see credentials that the PSR-18 client + * itself adds — a setup the $auth parameter explicitly allows. Those remain the + * caller's responsibility. + * + * @throws InvalidArgumentException If credentials would travel in cleartext. + */ + private static function assertCredentialsAreNotSentInClear(string $baseUrl, ?Auth $auth): void + { + if ($auth === null) { + return; + } + + $parts = parse_url($baseUrl); + + if (!is_array($parts) || ($parts['scheme'] ?? null) !== 'http') { + return; + } + + $host = $parts['host'] ?? ''; + + if (self::isLoopbackHost($host)) { + return; + } + + throw new InvalidArgumentException(sprintf( + 'Refusing to send Zammad credentials in cleartext to "http://%s". Use https://, or pass ' + . 'allowInsecureHttp: true (ConnectionConfig(allowInsecureHttp: true)) if the connection ' + . 'is already protected by other means.', + $host, + )); + } + + /** + * Whether the host resolves to the local machine by definition, i.e. without + * a DNS lookup: `localhost` and any `.localhost` subdomain (RFC 6761), the + * whole 127.0.0.0/8 range, and the IPv6 loopback `::1`. + * + * A host that merely happens to point at 127.0.0.1 today is not loopback here. + * That would require resolving it, and the answer could differ by the time the + * request goes out. + */ + private static function isLoopbackHost(string $host): bool + { + $host = strtolower(trim($host, '[]')); + + if ($host === 'localhost' || str_ends_with($host, '.localhost')) { + return true; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return str_starts_with($host, '127.'); + } + + return in_array($host, ['::1', '0:0:0:0:0:0:0:1'], true); } /** @@ -235,6 +325,12 @@ private function dispatch(string $method, string $uri, array $options): Response $request = $this->requestFactory->createRequest($method, $fullUri) ->withHeader('Accept', 'application/json'); + // Set before the per-request headers below so callers keep the last word, + // matching how a client-level default header behaves. + if ($this->auth !== null) { + $request = $request->withHeader('Authorization', $this->auth->header()); + } + if (isset($options['headers']) && is_array($options['headers'])) { foreach ($options['headers'] as $name => $value) { $request = $request->withHeader($name, $value); diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index 4d6d923..be00342 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -7,49 +7,70 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Transport\RequestHandler; +/** + * Builds a Guzzle-backed {@see RequestHandler}. + * + * Most callers never touch this class — {@see \ZammadAPIClient\ZammadClient::create()} + * wires it up for them. Use it directly only to hand a pre-built factory to the + * `ZammadClient` constructor, e.g. in a DI container definition. + */ final class GuzzleClientFactory implements ClientFactoryInterface { public const USER_AGENT = 'Zammad API PHP'; - private function __construct( + public function __construct( private readonly string $url, - private readonly string $authHeader, - private ?ConnectionConfig $config = null, + private readonly Auth $auth, + private readonly ?ConnectionConfig $config = null, ) { } + /** + * Shorthand for `new self($url, Auth::token($token))`. + */ public static function withToken( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Token token={$token}", $config); + return new self($url, Auth::token($token), $config); } + /** + * Shorthand for `new self($url, Auth::oauth2($token))`. + */ public static function withOAuth2( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Bearer {$token}", $config); + return new self($url, Auth::oauth2($token), $config); } + /** + * Shorthand for `new self($url, Auth::basic($user, $pass))`. + */ public static function withBasicAuth( string $url, string $user, - string $pass, + #[\SensitiveParameter] string $pass, ?ConnectionConfig $config = null, ): self { - return new self($url, 'Basic ' . base64_encode("{$user}:{$pass}"), $config); + return new self($url, Auth::basic($user, $pass), $config); } /** * Creates a request handler configured with this factory's credentials. + * + * The `Authorization` header is not set on the Guzzle client here — it is + * applied per request by {@see RequestHandler}, which keeps authentication + * working for any PSR-18 transport. */ public function createHandler(): RequestHandlerInterface { @@ -57,8 +78,7 @@ public function createHandler(): RequestHandlerInterface $httpClient = new GuzzleClient([ 'headers' => [ - 'User-Agent' => self::USER_AGENT, - 'Authorization' => $this->authHeader, + 'User-Agent' => self::USER_AGENT, ], 'verify' => $config->verifySsl, 'timeout' => $config->timeout, @@ -72,6 +92,8 @@ public function createHandler(): RequestHandlerInterface $this->url, logger: $config->logger ?? new NullLogger(), maxRetries: $config->maxRetries, + auth: $this->auth, + allowInsecureHttp: $config->allowInsecureHttp, ); } } diff --git a/src/ZammadClient.php b/src/ZammadClient.php index 23f89d9..bd561f3 100644 --- a/src/ZammadClient.php +++ b/src/ZammadClient.php @@ -4,9 +4,12 @@ namespace ZammadAPIClient; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Contracts\ClientInterface as ZammadClientInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Repository\RepositoryRegistry; use ZammadAPIClient\Core\Traits\RepositoryAccessors; @@ -14,13 +17,17 @@ /** * Entry point for the Zammad API. * - * $client = new ZammadClient( - * GuzzleClientFactory::withToken('https://zammad.example', 'your-token'), - * ); + * $client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); * * $client->ticket()->find(1); * $client->user()->all(); * + * To use a different HTTP transport, build the handler yourself and pass it to + * the constructor — credentials and retry behaviour work exactly the same: + * + * $handler = new RequestHandler($psr18, $psr17, $url, auth: Auth::token('your-token')); + * $client = new ZammadClient($handler); + * * @internal Prefer {@see ZammadClientInterface} for type hints. */ final class ZammadClient implements ZammadClientInterface @@ -40,6 +47,23 @@ public function __construct( : $source; } + /** + * Creates a client for the given Zammad instance, using Guzzle as transport. + * + * This is the shortcut for the default transport, not the only full-featured + * way in: passing your own {@see RequestHandlerInterface} to the constructor + * supports the same credentials and options. + * + * @param string $url The Zammad base URL; `/api/v1` is appended if missing. + */ + public static function create( + string $url, + Auth $auth, + ?ConnectionConfig $config = null, + ): self { + return new self(new GuzzleClientFactory($url, $auth, $config)); + } + public function getHandler(): RequestHandlerInterface { return $this->handler; diff --git a/test/Unit/Core/AuthTest.php b/test/Unit/Core/AuthTest.php new file mode 100644 index 0000000..a179896 --- /dev/null +++ b/test/Unit/Core/AuthTest.php @@ -0,0 +1,137 @@ +header()); + } + + public function testOAuth2BuildsBearerHeader(): void + { + self::assertSame('Bearer secret', Auth::oauth2('secret')->header()); + } + + public function testBasicBuildsBase64EncodedHeader(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin@example.com:hunter2'), + Auth::basic('admin@example.com', 'hunter2')->header(), + ); + } + + public function testEmptyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('API token must not be empty.'); + + Auth::token(''); + } + + public function testWhitespaceOnlyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + Auth::token(" \t "); + } + + public function testEmptyOAuth2TokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('OAuth2 token must not be empty.'); + + Auth::oauth2(''); + } + + public function testEmptyUsernameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Username must not be empty.'); + + Auth::basic('', 'hunter2'); + } + + /** + * RFC 7617 permits an empty password, so only the username is mandatory. + */ + public function testEmptyPasswordIsAccepted(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin:'), + Auth::basic('admin', '')->header(), + ); + } + + /** + * A token read from a file or an environment variable commonly carries a + * trailing newline. Untrimmed it reaches PSR-7, which rejects it as an + * invalid header value and blames the transport; leading whitespace is + * worse, surviving into the header and coming back as a plain 401. + * + * @return iterable + */ + public static function paddedTokens(): iterable + { + yield 'trailing newline' => ["secret\n"]; + yield 'trailing space' => ['secret ']; + yield 'leading space' => [' secret']; + yield 'leading tab' => ["\tsecret"]; + yield 'both sides' => [" secret\r\n"]; + } + + #[DataProvider('paddedTokens')] + public function testSurroundingWhitespaceIsStripped(string $padded): void + { + self::assertSame('Token token=secret', Auth::token($padded)->header()); + } + + public function testUsernameIsTrimmedButPasswordIsNot(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin: hunter2 '), + Auth::basic(' admin ', ' hunter2 ')->header(), + ); + } + + public function testDebugOutputDoesNotLeakTheCredential(): void + { + $dump = print_r(Auth::token('super-secret'), true); + + self::assertStringNotContainsString('super-secret', $dump); + self::assertStringContainsString('redacted', $dump); + } + + /** + * An empty username throws from inside {@see Auth::basic()}, so that frame — + * password and all — lands in the stack trace. `#[\SensitiveParameter]` is what + * keeps the password out of it; without the attribute this test fails. + */ + public function testStackTraceDoesNotLeakTheBasicAuthPassword(): void + { + if (PHP_VERSION_ID < 80200) { + self::markTestSkipped('#[\SensitiveParameter] is honoured from PHP 8.2 on.'); + } + if (ini_get('zend.exception_ignore_args')) { + self::markTestSkipped('Arguments are stripped from traces by zend.exception_ignore_args.'); + } + + try { + Auth::basic('', 'hunter2'); + self::fail('Expected an InvalidArgumentException for the empty username.'); + } catch (InvalidArgumentException $e) { + self::assertStringNotContainsString('hunter2', $e->getTraceAsString()); + self::assertStringContainsString('SensitiveParameterValue', $e->getTraceAsString()); + } + } +} diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 98ad0a1..cae047f 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -7,6 +7,7 @@ use GuzzleHttp\Psr7\Response; use InvalidArgumentException; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; @@ -14,6 +15,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -366,4 +368,144 @@ public function testDeleteReturnsEmptyArrayOnEmptyBody(): void self::assertSame([], $result); } + + /** + * The recording client below is a plain PSR-18 implementation, not Guzzle — + * so this also demonstrates that authentication no longer depends on the + * transport offering client-level default headers. + */ + public function testAuthorizationHeaderIsSentWhenAuthIsConfigured(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testNoAuthorizationHeaderWithoutAuth(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + + $this->handler->get('tickets'); + + self::assertFalse($this->httpClient->lastRequest?->hasHeader('Authorization')); + } + + /** + * getRaw() is the attachment path and used to rely on the client-level + * default header, so it is worth pinning down separately. + */ + public function testAuthorizationHeaderIsSentOnRawRequests(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], 'binary-payload'); + + $handler->getRaw('ticket_attachment/1/2/3'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testPerRequestHeaderOverridesConfiguredAuth(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->request('GET', 'tickets', ['headers' => ['Authorization' => 'Bearer override']]); + + self::assertSame( + 'Bearer override', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testCredentialsOverPlainHttpAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refusing to send Zammad credentials in cleartext to "http://zammad.example"'); + + $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'http://zammad.example', + ); + } + + public function testCredentialsOverPlainHttpArePermittedWithOptOut(): void + { + $handler = $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'http://zammad.example', + allowInsecureHttp: true, + ); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + /** + * A credential that never leaves the machine is not at risk, so the guard + * must not fire on the loopback defaults this library ships with. + */ + #[DataProvider('loopbackUrlProvider')] + public function testCredentialsOverPlainHttpArePermittedOnLoopback(string $url): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret'), baseUrl: $url); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + /** + * @return iterable + */ + public static function loopbackUrlProvider(): iterable + { + yield 'localhost' => ['http://localhost:3000']; + yield 'localhost subdomain' => ['http://zammad.localhost']; + yield 'IPv4 loopback' => ['http://127.0.0.1:8098']; + yield 'IPv4 loopback range' => ['http://127.0.0.2:3000']; + yield 'IPv6 loopback' => ['http://[::1]:3000']; + } + + public function testHttpsIsNeverRejected(): void + { + $handler = $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'https://zammad.example', + ); + + self::assertInstanceOf(RequestHandler::class, $handler); + } + + /** + * The guard can only see credentials it was given. A handler built without + * an Auth is the documented case for a PSR-18 client that adds the header + * itself — invisible from here, and therefore not rejected. + */ + public function testPlainHttpWithoutAuthIsNotRejected(): void + { + $handler = $this->createHandler($this->httpClient, baseUrl: 'http://zammad.example'); + + self::assertInstanceOf(RequestHandler::class, $handler); + } } diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php index 77d3ddc..4ac1df6 100644 --- a/test/Unit/Core/Traits/CreatesRequestHandler.php +++ b/test/Unit/Core/Traits/CreatesRequestHandler.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\HttpFactory; use Psr\Http\Client\ClientInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; /** @@ -22,8 +23,20 @@ private function setUpRequestHandler(): void $this->httpFactory = new HttpFactory(); } - private function createHandler(ClientInterface $client, int $maxRetries = 0): RequestHandler - { - return new RequestHandler($client, $this->httpFactory, self::BASE_URL, maxRetries: $maxRetries); + private function createHandler( + ClientInterface $client, + int $maxRetries = 0, + ?Auth $auth = null, + ?string $baseUrl = null, + bool $allowInsecureHttp = false, + ): RequestHandler { + return new RequestHandler( + $client, + $this->httpFactory, + $baseUrl ?? self::BASE_URL, + maxRetries: $maxRetries, + auth: $auth, + allowInsecureHttp: $allowInsecureHttp, + ); } } diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index fceaa26..1a6a0a5 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -4,9 +4,16 @@ namespace ZammadAPIClient\Tests\Unit; +use InvalidArgumentException; use Mockery\Adapter\Phpunit\MockeryTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use ReflectionProperty; +use ZammadAPIClient\Core\ConnectionConfig; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\ClientInterface; +use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; @@ -42,4 +49,83 @@ public function testFactoryWrappedInZammadClient(): void self::assertInstanceOf(ClientInterface::class, $client); } + + /** + * The three static helpers are shorthand for constructing the factory with an + * explicit {@see Auth}, so these calls are deliberate: they pin down that both + * forms stay equivalent. Do not remove them as leftovers. + * + * @return iterable + */ + public static function equivalentFactories(): iterable + { + $url = 'https://zammad.example'; + + yield 'token' => [ + GuzzleClientFactory::withToken($url, 'test-token'), + new GuzzleClientFactory($url, Auth::token('test-token')), + ]; + yield 'oauth2' => [ + GuzzleClientFactory::withOAuth2($url, 'oauth-token'), + new GuzzleClientFactory($url, Auth::oauth2('oauth-token')), + ]; + yield 'basic' => [ + GuzzleClientFactory::withBasicAuth($url, 'admin', 'test'), + new GuzzleClientFactory($url, Auth::basic('admin', 'test')), + ]; + } + + #[DataProvider('equivalentFactories')] + public function testLegacyHelperMatchesExplicitAuthConstructor( + GuzzleClientFactory $legacy, + GuzzleClientFactory $explicit, + ): void { + self::assertSame( + self::authHeaderOf($legacy->createHandler()), + self::authHeaderOf($explicit->createHandler()), + ); + } + + public function testZammadClientCreateUsesTheGivenAuth(): void + { + $client = ZammadClient::create('https://zammad.example', Auth::token('test-token')); + + self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); + } + + /** + * The cleartext guard lives in RequestHandler, but allowInsecureHttp has to + * travel ConnectionConfig → factory → handler to be reachable from + * ZammadClient::create(). These two pin down that wiring. + */ + public function testCreateRejectsCredentialsOverPlainHttp(): void + { + $this->expectException(InvalidArgumentException::class); + + ZammadClient::create('http://zammad.example', Auth::token('test-token')); + } + + public function testCreateForwardsTheInsecureHttpOptOut(): void + { + $client = ZammadClient::create( + 'http://zammad.example', + Auth::token('test-token'), + new ConnectionConfig(allowInsecureHttp: true), + ); + + self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); + } + + /** + * Reads the credential the handler will apply. White-box on purpose: the + * Guzzle-backed path cannot be observed without making a real request. + */ + private static function authHeaderOf(RequestHandlerInterface $handler): string + { + $auth = (new ReflectionProperty(RequestHandler::class, 'auth'))->getValue($handler); + + self::assertInstanceOf(Auth::class, $auth); + + return $auth->header(); + } }