diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..564996f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,37 @@
+name: Tests
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ runs-on: ubuntu-22.04
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ dependencies: [lowest, highest]
+ name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: curl, json, simplexml, libxml
+ tools: composer:v2
+ coverage: none
+ - run: composer validate --strict
+ - name: Install dependencies
+ run: |
+ if [ '${{ matrix.dependencies }}' = 'lowest' ]; then
+ composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction
+ else
+ composer update --prefer-stable --prefer-dist --no-interaction
+ fi
+ - run: composer audit
+ - run: composer lint
+ - run: composer test
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b452ba5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+## 2.0.0 (unreleased)
+
+### Breaking changes
+
+- Require PHP 7.2.5+ (PHP 7 or 8) and Guzzle 7.15.2+. The previous dependency
+ range included Guzzle 6 and older PHP releases. Current Composer advisory
+ checks cannot resolve that legacy combination without exceptions.
+- Require `overtrue/http` 1.2.3+, the first 1.x release containing both the
+ PHP 8 collection compatibility and resource-upload corrections used here.
+- Endpoint paths beginning with a single slash now resolve under the configured
+ API base path, matching the documented `/mail/send` usage. Callers that need an
+ origin-root path must supply an absolute URL.
+
+- Redirect following is disabled by default. Explicitly enabled redirects must
+ remain on the initial scheme, host, and port; cross-origin/downgrade redirects
+ throw before a second request is sent.
+
+- Prebuilt multipart bodies and low-level HTTP-client multipart calls are rejected;
+ use the SDK `upload()` or `request()` multipart option instead.
+
+### Fixed
+
+- Use HTTPS by default and honor an explicitly configured base URI.
+- Add POST authentication to URL-encoded form bodies or multipart fields.
+- Preserve existing query parameters and form values, including duplicate and
+ encoded values; use correctly encoded authentication values.
+- Remove the obsolete PSR-7 `modify_request()` dependency.
+- Retain response casting, API-level failure responses, transport/HTTP exceptions,
+ and asynchronous behavior.
+
+### Tests
+
+- Add mocked outgoing-request regressions, syntax checks, dependency audits, and
+ lowest/latest dependency CI for PHP 7.2–7.4 and 8.0–8.5.
diff --git a/README.md b/README.md
index 1317c5c..88b85b4 100644
--- a/README.md
+++ b/README.md
@@ -18,12 +18,14 @@ $apiKey = 'UWoBGa2sgxyxxxxxxxx';
$client = new SendCloud($apiUser, $apiKey);
-$result = $client->post('/mail/send', [
+$message = [
'from' => 'overtrue@domain.sendcloud.org',
'to' => 'demo@easywechat.com',
'subject' => '来自 SendCloud 的第一封邮件!',
'html' => '你太棒了!你已成功的 从 SendCloud 发送了一封测试邮件!',
-]);
+];
+
+$result = $client->post('mail/send', $message);
var_dump($result);
@@ -40,6 +42,72 @@ var_dump($result);
```
+## Requirements and compatibility
+
+Version 2 requires PHP 7.2.5+ (PHP 7 or 8), Guzzle 7.15.2+, and
+`overtrue/http` 1.2.3+. Use a currently supported PHP release for production.
+The PHP minimum and Guzzle 7 requirement are breaking changes from version 1.
+
+Requests use `https://api.sendcloud.net/apiv2/` by default. Endpoint paths such
+as `mail/send` and `/mail/send` are resolved under this base path. An explicitly
+configured `base_uri` is honored; include its trailing slash. Absolute URLs
+retain Guzzle's normal behavior. Only use trusted endpoints with your API keys. Prefer HTTPS for every configured
+or absolute endpoint; an explicitly supplied HTTP URL is not upgraded.
+
+Redirects are disabled by default. If you explicitly set `allow_redirects` to
+`true` or a Guzzle redirect-options array, redirects must retain the original
+scheme, host, and port. Cross-origin redirects and HTTPS-to-HTTP downgrades throw
+a request exception before credentials or payloads can be forwarded. Existing
+`on_redirect` callbacks are called only after this check passes.
+
+```php
+$client = new SendCloud($apiUser, $apiKey, [
+ 'base_uri' => 'https://api.sendcloud.net/apiv2/',
+ 'response_type' => 'array',
+]);
+```
+
+POST form requests carry authentication in the form body. Use `upload()` for
+attachments; authentication is added as multipart fields without URL-encoding
+those field values. You can also pass the `multipart` option to the SDK's
+`request()` method. Prebuilt multipart bodies and low-level HTTP-client multipart
+calls are rejected; use these SDK entry points so caller-supplied authentication
+fields can be replaced before encoding. GET authentication is added to the query without removing
+other parameters. Non-form request bodies are left unchanged and authentication
+is added to their query. Response types and exception behavior remain those of
+`overtrue/http`; API-level failures are not converted into exceptions.
+
+```php
+$result = $client->upload('mail/send', [
+ 'attachments' => '/path/to/report.pdf',
+], [
+ 'from' => 'sender@example.com',
+ 'to' => 'recipient@example.com',
+ 'subject' => 'Report',
+ 'html' => 'Please see the attachment.',
+]);
+
+$result = $client->postAsync('mail/send', $message)->wait();
+```
+
+See SendCloud's [delivery contract](https://www.sendcloud.net/doc/en/email_v2/send_email/)
+and [GET API contract](https://www.sendcloud.net/doc/en/email_v2/apiuser_do/).
+
+## Tests
+
+```shell
+composer install
+composer validate --strict
+composer test
+composer lint
+composer audit
+```
+
+The dependency-free regression runner uses Guzzle's `MockHandler` and synthetic
+credentials. It retains the real authentication middleware and sends no email or
+live API requests. CI tests lowest and latest installable dependencies without
+disabling Composer's advisory checks.
+
## License
MIT
diff --git a/composer.json b/composer.json
index e46fa61..7544a48 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,12 @@
}
},
"require": {
- "overtrue/http": "^1.0"
+ "php": "^7.2.5 || ^8.0",
+ "guzzlehttp/guzzle": "^7.15.2",
+ "overtrue/http": "^1.2.3"
+ },
+ "scripts": {
+ "test": "@php tests/run.php",
+ "lint": "@php tests/lint.php"
}
}
diff --git a/src/SendCloud.php b/src/SendCloud.php
index 84d4100..7accb4b 100644
--- a/src/SendCloud.php
+++ b/src/SendCloud.php
@@ -10,10 +10,17 @@
namespace Overtrue\SendCloud;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\AppendStream;
+use GuzzleHttp\Psr7\LimitStream;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Stream;
use Overtrue\Http\Client;
use Overtrue\Http\Config;
use Psr\Http\Message\RequestInterface;
-use function GuzzleHttp\Psr7\modify_request;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\UriInterface;
/**
* Class SendCloud.
@@ -22,7 +29,7 @@
*/
class SendCloud extends Client
{
- const BASE_URI = 'http://api.sendcloud.net/apiv2/';
+ const BASE_URI = 'https://api.sendcloud.net/apiv2/';
/**
* @var string
@@ -46,7 +53,7 @@ public function __construct(string $apiUser, string $apiKey, array $config = [])
$this->apiUser = $apiUser;
$this->apiKey = $apiKey;
- $config['base_uri'] = self::BASE_URI;
+ $config += ['base_uri' => self::BASE_URI, 'allow_redirects' => false];
parent::__construct(new Config($config));
@@ -60,19 +67,128 @@ public function authorizationParamsMiddleware()
{
return function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
- $credential = http_build_query([
+ $credentials = [
'apiUser' => $this->apiUser,
- 'apiKey' => $this->apiKey,
- ]);
+ 'apiKey' => $this->apiKey,
+ ];
+ $contentType = strtolower(trim(explode(';', $request->getHeaderLine('Content-Type'))[0]));
+ $body = $request->getBody();
- if ('post' == $request->getMethod()) {
- $request = modify_request($request, ['body' => $request->getBody()->getContents().'&'.$credential]);
+ if ('POST' === strtoupper($request->getMethod()) && $body instanceof MultipartStream) {
+ if (empty($options['sendcloud_multipart'])) {
+ throw new \InvalidArgumentException('Use SendCloud::upload() or its multipart request option instead of a prebuilt multipart body.');
+ }
+ $parts = [];
+ foreach ($credentials as $name => $contents) {
+ $parts[] = compact('name', 'contents');
+ }
+ $auth = new MultipartStream($parts, $body->getBoundary());
+ // Keep the original multipart stream and its closing boundary intact.
+ $prefix = new LimitStream($auth, $auth->getSize() - strlen('--'.$body->getBoundary()."--\r\n"));
+ $request = $request->withBody(new AppendStream([$prefix, $body]));
+ $request = $this->withBodyLength($request);
+ } elseif ('POST' === strtoupper($request->getMethod())
+ && ('application/x-www-form-urlencoded' === $contentType || ('' === $contentType && 0 === $body->getSize()))) {
+ $stream = new Stream(fopen('php://temp', 'r+'));
+ $stream->write($this->withCredentials((string) $body, $credentials));
+ $stream->rewind();
+ $request = $request->withBody($stream);
+ if ('' === $contentType) {
+ $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded');
+ }
+ $request = $this->withBodyLength($request);
} else {
- $request = $request->withUri($request->getUri()->withQuery($credential));
+ // Leave non-form bodies untouched, preserving unrelated query parameters.
+ $request = $request->withUri($request->getUri()->withQuery(
+ $this->withCredentials($request->getUri()->getQuery(), $credentials)
+ ));
}
return $handler($request, $options);
};
};
}
+
+ /**
+ * Guard redirects before Guzzle follows them or authentication is added again.
+ */
+ public function getHandlerStack(): HandlerStack
+ {
+ $needsGuard = null === $this->handlerStack;
+ $stack = parent::getHandlerStack();
+
+ if ($needsGuard) {
+ $stack->before('allow_redirects', function (callable $handler) {
+ return function (RequestInterface $request, array $options) use ($handler) {
+ $redirects = $options['allow_redirects'] ?? false;
+ if (true === $redirects || (is_array($redirects) && $redirects)) {
+ $redirects = true === $redirects ? [] : $redirects;
+ $callback = $redirects['on_redirect'] ?? null;
+ $origin = $request->getUri();
+ $redirects['on_redirect'] = function (RequestInterface $request, ResponseInterface $response, UriInterface $target) use ($origin, $callback) {
+ if ($origin->getScheme() !== $target->getScheme()
+ || $origin->getHost() !== $target->getHost()
+ || $origin->getPort() !== $target->getPort()
+ || $origin->getUserInfo() !== $target->getUserInfo()) {
+ throw new RequestException('SendCloud redirects must stay on the original origin.', $request, $response);
+ }
+ if (null !== $callback) {
+ $callback($request, $response, $target);
+ }
+ };
+ $options['allow_redirects'] = $redirects;
+ }
+
+ return $handler($request, $options);
+ };
+ }, 'sendcloud_redirect_guard');
+ }
+
+ return $stack;
+ }
+
+ /**
+ * Treat API endpoint paths with a leading slash like their relative form.
+ */
+ public function requestRaw(string $uri, string $method = 'GET', array $options = [], bool $async = false)
+ {
+ if (isset($uri[0]) && '/' === $uri[0] && substr($uri, 0, 2) !== '//') {
+ $uri = ltrim($uri, '/');
+ }
+
+ unset($options['sendcloud_multipart']);
+ if ('POST' === strtoupper($method) && isset($options['multipart'])) {
+ $options['sendcloud_multipart'] = true;
+ $options['multipart'] = array_values(array_filter($options['multipart'], function ($part) {
+ return !isset($part['name']) || !in_array($part['name'], ['apiUser', 'apiKey'], true);
+ }));
+ }
+
+ return parent::requestRaw($uri, $method, $options, $async);
+ }
+
+ /**
+ * Preserve encoded values and repeated parameters while replacing credentials.
+ */
+ protected function withCredentials(string $query, array $credentials): string
+ {
+ $parameters = array_filter(explode('&', $query), function ($parameter) use ($credentials) {
+ return '' !== $parameter && !array_key_exists(urldecode(explode('=', $parameter, 2)[0]), $credentials);
+ });
+ $parameters[] = http_build_query($credentials, '', '&', PHP_QUERY_RFC3986);
+
+ return implode('&', $parameters);
+ }
+
+ protected function withBodyLength(RequestInterface $request): RequestInterface
+ {
+ $request = $request->withoutHeader('Content-Length');
+ $size = $request->getBody()->getSize();
+
+ if (null !== $size && !$request->hasHeader('Transfer-Encoding')) {
+ $request = $request->withHeader('Content-Length', (string) $size);
+ }
+
+ return $request;
+ }
}
diff --git a/tests/lint.php b/tests/lint.php
new file mode 100644
index 0000000..fabba85
--- /dev/null
+++ b/tests/lint.php
@@ -0,0 +1,12 @@
+isFile() && 'php' === $file->getExtension()) {
+ passthru(escapeshellarg(PHP_BINARY).' -l '.escapeshellarg($file->getPathname()), $status);
+ $failed = $failed || 0 !== $status;
+ }
+ }
+}
+exit($failed ? 1 : 0);
diff --git a/tests/run.php b/tests/run.php
new file mode 100644
index 0000000..b40570e
--- /dev/null
+++ b/tests/run.php
@@ -0,0 +1,395 @@
+getHandlerStack()->setHandler(new MockHandler($responses ?: [new Response(200, [], '{"result":true}')]));
+ $client->getHandlerStack()->push(Middleware::history($history));
+
+ return $client;
+}
+
+function form($request)
+{
+ parse_str((string) $request->getBody(), $form);
+ return $form;
+}
+
+function auth(array $parameters)
+{
+ same(API_USER, $parameters['apiUser']);
+ same(API_KEY, $parameters['apiKey']);
+}
+
+function lengthMatches($request)
+{
+ same((string) strlen((string) $request->getBody()), $request->getHeaderLine('Content-Length'));
+}
+
+$tests = [];
+function addTest($name, Closure $test)
+{
+ global $tests;
+ if (array_key_exists($name, $tests)) {
+ throw new RuntimeException('Duplicate test name: '.$name);
+ }
+ $tests[$name] = $test;
+}
+
+addTest('HTTPS, documented path and POST body authentication', function () {
+ $c = client($history);
+ same(['result' => true], $c->post('/mail/send?trace=one', ['subject' => '你好 +&= 100%', 'html' => 'hello']));
+ $r = $history[0]['request'];
+ same('https://api.sendcloud.net/apiv2/mail/send?trace=one', (string) $r->getUri());
+ same('POST', $r->getMethod());
+ auth(form($r));
+ same('你好 +&= 100%', form($r)['subject']);
+ same('hello', form($r)['html']);
+ same('application/x-www-form-urlencoded', $r->getHeaderLine('Content-Type'));
+ lengthMatches($r);
+});
+addTest('relative paths and custom base URI', function () {
+ $c = client($history, ['base_uri' => 'https://example.test/custom/']);
+ $c->post('mail/send', []);
+ same('https://example.test/custom/mail/send', (string) $history[0]['request']->getUri());
+ auth(form($history[0]['request']));
+});
+addTest('absolute URLs retain Guzzle resolution', function () {
+ $c = client($history);
+ $c->get('https://example.test/exact?x=1');
+ same('example.test', $history[0]['request']->getUri()->getHost());
+ same('/exact', $history[0]['request']->getUri()->getPath());
+});
+addTest('GET preserves repeated and encoded parameters', function () {
+ $c = client($history);
+ $c->get('/label/list?x=1&x=2&value=a%2Bb%20c&apiUser=wrong&apiKey=wrong');
+ $q = $history[0]['request']->getUri()->getQuery();
+ check(0 === strpos($q, 'x=1&x=2&value=a%2Bb%20c&'), 'Query bytes changed');
+ parse_str($q, $query);
+ auth($query);
+ same(1, substr_count($q, 'apiUser='));
+ same(1, substr_count($q, 'apiKey='));
+});
+addTest('Guzzle query option retains data', function () {
+ $c = client($history);
+ $c->get('label/list', ['query' => ['labelId' => 42, 'name' => '测试 +']]);
+ parse_str($history[0]['request']->getUri()->getQuery(), $query);
+ auth($query);
+ same('42', $query['labelId']);
+ same('测试 +', $query['name']);
+});
+addTest('form credentials replaced without losing duplicate values', function () {
+ $c = client($history);
+ $c->request('mail/send', 'post', ['body' => 'x=1&x=2&api%55ser=wrong&apiKey=wrong', 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8']]);
+ $r = $history[0]['request'];
+ check(0 === strpos((string) $r->getBody(), 'x=1&x=2&'), 'Duplicate form values lost');
+ same('application/x-www-form-urlencoded; charset=UTF-8', $r->getHeaderLine('Content-Type'));
+ auth(form($r));
+ same(1, substr_count((string) $r->getBody(), 'apiUser='));
+ lengthMatches($r);
+});
+addTest('empty POST becomes authenticated form', function () {
+ $c = client($history);
+ $c->requestRaw('mail/send', 'POST');
+ auth(form($history[0]['request']));
+ same('', $history[0]['request']->getUri()->getQuery());
+});
+addTest('multipart attachment and credentials use one valid boundary', function () {
+ $c = client($history);
+ $file = fopen('php://temp', 'r+');
+ fwrite($file, "binary\0attachment\r\n你好");
+ rewind($file);
+ $c->upload('/mail/send?trace=1', ['attachments' => $file], ['subject' => '你好', 'apiUser' => 'wrong', 'apiKey' => 'wrong']);
+ $r = $history[0]['request'];
+ $body = (string) $r->getBody();
+ preg_match('/boundary=(.*)/', $r->getHeaderLine('Content-Type'), $match);
+ $boundary = $match[1];
+ same(1, substr_count($body, '--'.$boundary."--\r\n"));
+ same(1, substr_count($body, 'name="apiUser"'));
+ same(1, substr_count($body, 'name="apiKey"'));
+ check(false !== strpos($body, "\r\n\r\n".API_USER."\r\n"), 'Missing raw multipart user');
+ check(false !== strpos($body, "\r\n\r\n".API_KEY."\r\n"), 'Missing raw multipart key');
+ check(false !== strpos($body, "binary\0attachment\r\n你好"), 'Attachment changed');
+ same('trace=1', $r->getUri()->getQuery());
+ lengthMatches($r);
+ fclose($file);
+});
+addTest('JSON remains unchanged, authentication preserves query', function () {
+ $c = client($history);
+ $c->request('custom?trace=1', 'POST', ['json' => ['value' => '你好']]);
+ $r = $history[0]['request'];
+ same(['value' => '你好'], json_decode((string) $r->getBody(), true));
+ parse_str($r->getUri()->getQuery(), $q);
+ auth($q);
+ same('1', $q['trace']);
+});
+addTest('repeated calls never accumulate authentication or lose payload', function () {
+ $c = client($history, [], [new Response(200, [], '{}'), new Response(200, [], '{}')]);
+ for ($i = 0; $i < 2; ++$i) {
+ $c->post('/mail/send', ['subject' => 'repeat']);
+ $r = $history[$i]['request'];
+ auth(form($r));
+ same('repeat', form($r)['subject']);
+ same(1, substr_count((string) $r->getBody(), 'apiKey='));
+ }
+ same((string) $history[0]['request']->getBody(), (string) $history[1]['request']->getBody());
+});
+foreach (['array', 'object', 'collection', 'raw'] as $type) {
+ addTest('response type '.$type.' remains unchanged', function () use ($type) {
+ $c = client($history, ['response_type' => $type]);
+ $result = $c->post('mail/send');
+ if ('array' === $type) {
+ same(['result' => true], $result);
+ } elseif ('object' === $type) {
+ same(true, $result->result);
+ } elseif ('collection' === $type) {
+ check($result instanceof Collection, 'Not a collection');
+ same(true, $result['result']);
+ } else {
+ check($result instanceof ResponseInterface, 'Not a raw response');
+ same('{"result":true}', (string) $result->getBody());
+ }
+ });
+}
+addTest('API-level failure stays a response', function () {
+ $payload = ['result' => false, 'statusCode' => 40001, 'message' => 'synthetic failure'];
+ $c = client($history, [], [new Response(200, [], json_encode($payload))]);
+ same($payload, $c->post('mail/send'));
+});
+addTest('HTTP failure propagates', function () {
+ $c = client($history, [], [new Response(400, [], '{"result":false}')]);
+ try {
+ $c->post('mail/send');
+ } catch (ClientException $e) {
+ same(400, $e->getResponse()->getStatusCode());
+ return;
+ }
+ throw new RuntimeException('Expected ClientException');
+});
+addTest('http_errors option remains configurable', function () {
+ $c = client($history, [], [new Response(400, [], '{"result":false}')]);
+ same(['result' => false], $c->post('mail/send', [], ['http_errors' => false]));
+});
+addTest('transport errors propagate', function () {
+ $error = new ConnectException('synthetic connection failure', new Request('POST', 'https://example.test/'));
+ $c = client($history, [], [$error]);
+ try {
+ $c->post('mail/send');
+ } catch (ConnectException $e) {
+ same($error, $e);
+ return;
+ }
+ throw new RuntimeException('Expected ConnectException');
+});
+addTest('async POST returns promise and authenticates', function () {
+ $c = client($history);
+ $promise = $c->postAsync('/mail/send', ['subject' => 'async']);
+ check($promise instanceof PromiseInterface, 'Not a promise');
+ same(['result' => true], $promise->wait());
+ auth(form($history[0]['request']));
+});
+addTest('async GET and raw response', function () {
+ $c = client($history, ['response_type' => 'raw']);
+ $result = $c->getAsync('/label/list?labelId=1')->wait();
+ check($result instanceof ResponseInterface, 'Not a raw response');
+ parse_str($history[0]['request']->getUri()->getQuery(), $q);
+ auth($q);
+ same('1', $q['labelId']);
+});
+addTest('async HTTP errors reject', function () {
+ $c = client($history, [], [new Response(400)]);
+ try {
+ $c->postAsync('mail/send')->wait();
+ } catch (ClientException $e) {
+ same(400, $e->getResponse()->getStatusCode());
+ return;
+ }
+ throw new RuntimeException('Expected async rejection');
+});
+
+addTest('async multipart preserves form and file bytes', function () {
+ $c = client($history);
+ $file = fopen('php://temp', 'r+');
+ fwrite($file, 'async attachment');
+ rewind($file);
+ same(['result' => true], $c->uploadAsync('mail/send', ['attachments' => $file], ['subject' => 'async'])->wait());
+ $r = $history[0]['request'];
+ check(false !== strpos((string) $r->getBody(), 'async attachment'), 'Async file missing');
+ check(false !== strpos((string) $r->getBody(), API_KEY), 'Async multipart authentication missing');
+ same('', $r->getUri()->getQuery());
+ lengthMatches($r);
+ fclose($file);
+});
+addTest('partially read form streams retain the full encoded body', function () {
+ $c = client($history);
+ $stream = new GuzzleHttp\Psr7\Stream(fopen('php://temp', 'r+'));
+ $stream->write('subject=hello&x=1');
+ $stream->rewind();
+ $stream->read(5);
+ $c->request('mail/send', 'POST', ['body' => $stream, 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded']]);
+ $r = $history[0]['request'];
+ auth(form($r));
+ same('hello', form($r)['subject']);
+ same('1', form($r)['x']);
+ lengthMatches($r);
+});
+
+foreach ([302, 307, 308] as $code) {
+ foreach (['get', 'post', 'multipart'] as $mode) {
+ addTest('redirects disabled '.$code.' '.$mode, function () use ($code, $mode) {
+ $c = client($history, ['response_type' => 'raw'], [new Response($code, ['Location' => 'https://other.example.test/stolen'])]);
+ if ('get' === $mode) {
+ $response = $c->get('label/list?private=value');
+ } elseif ('post' === $mode) {
+ $response = $c->post('mail/send', ['subject' => 'private payload']);
+ } else {
+ $response = $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]]);
+ }
+ same($code, $response->getStatusCode());
+ same(1, count($history));
+ });
+ foreach (['https://other.example.test/stolen', 'http://api.sendcloud.net/stolen', 'https://api.sendcloud.net:444/stolen', '//other.example.test/stolen'] as $target) {
+ addTest('unsafe redirect '.$code.' '.$mode.' '.$target, function () use ($code, $mode, $target) {
+ $callbackCalls = 0;
+ $c = client($history, ['allow_redirects' => ['on_redirect' => function () use (&$callbackCalls) { ++$callbackCalls; }]], [new Response($code, ['Location' => $target]), new Response(200, [], '{}')]);
+ try {
+ if ('get' === $mode) {
+ $c->get('label/list?private=value');
+ } elseif ('post' === $mode) {
+ $c->post('mail/send', ['subject' => 'private payload']);
+ } else {
+ $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]]);
+ }
+ } catch (GuzzleHttp\Exception\RequestException $e) {
+ same(1, count($history));
+ same(0, $callbackCalls);
+ same($code, $e->getResponse()->getStatusCode());
+ return;
+ }
+ throw new RuntimeException('Unsafe redirect was followed');
+ });
+ }
+ }
+}
+addTest('async unsafe redirect rejects before second transport', function () {
+ $c = client($history, [], [new Response(307, ['Location' => 'https://other.example.test/stolen']), new Response(200, [], '{}')]);
+ try {
+ $c->postAsync('mail/send', ['subject' => 'private'], ['allow_redirects' => true])->wait();
+ } catch (GuzzleHttp\Exception\RequestException $e) {
+ same(1, count($history));
+ return;
+ }
+ throw new RuntimeException('Expected rejected redirect promise');
+});
+addTest('custom origin same-origin redirect preserves multipart and callback', function () {
+ $calls = 0;
+ $c = client($history, ['base_uri' => 'https://example.test/custom/', 'allow_redirects' => ['on_redirect' => function () use (&$calls) { ++$calls; }]], [new Response(307, ['Location' => 'https://example.test:443/next']), new Response(200, [], '{"result":true}')]);
+ same(['result' => true], $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]]));
+ same(2, count($history));
+ same(1, $calls);
+ foreach ($history as $transaction) {
+ $r = $transaction['request'];
+ same('example.test', $r->getUri()->getHost());
+ same('POST', $r->getMethod());
+ check(false !== strpos((string) $r->getBody(), 'private bytes'), 'Multipart bytes lost on redirect');
+ same(1, substr_count((string) $r->getBody(), 'name="apiKey"'));
+ lengthMatches($r);
+ }
+});
+addTest('custom origin cannot redirect to the default API origin', function () {
+ $c = client($history, ['base_uri' => 'https://example.test/custom/', 'allow_redirects' => true], [new Response(302, ['Location' => SendCloud::BASE_URI]), new Response(200, [], '{}')]);
+ try {
+ $c->get('label/list');
+ } catch (GuzzleHttp\Exception\RequestException $e) {
+ same(1, count($history));
+ return;
+ }
+ throw new RuntimeException('Custom-origin boundary not enforced');
+});
+addTest('direct HTTP client retains the redirect guard', function () {
+ $c = client($history, [], [new Response(302, ['Location' => 'https://other.example.test/stolen']), new Response(200, [], '{}')]);
+ try {
+ $c->getHttpClient()->request('GET', 'label/list', ['allow_redirects' => true]);
+ } catch (GuzzleHttp\Exception\RequestException $e) {
+ same(1, count($history));
+ return;
+ }
+ throw new RuntimeException('Direct client bypassed redirect guard');
+});
+
+foreach (['request', 'raw-client'] as $entry) {
+ addTest('reject ambiguous prebuilt multipart '.$entry, function () use ($entry) {
+ $c = client($history);
+ $body = new GuzzleHttp\Psr7\MultipartStream([['name' => 'apiKey', 'contents' => 'wrong'], ['name' => 'subject', 'contents' => 'private']]);
+ try {
+ if ('request' === $entry) {
+ $c->request('mail/send', 'POST', ['body' => $body]);
+ } else {
+ $c->getHttpClient()->request('POST', 'mail/send', ['body' => $body]);
+ }
+ } catch (InvalidArgumentException $e) {
+ same(0, count($history));
+ check(false !== strpos($e->getMessage(), 'multipart request option'), 'Missing migration guidance');
+ return;
+ }
+ throw new RuntimeException('Ambiguous multipart body was sent');
+ });
+}
+
+if (!$tests) {
+ throw new RuntimeException('No regression tests registered.');
+}
+
+$failures = 0;
+foreach ($tests as $name => $test) {
+ try {
+ $test();
+ echo "PASS: $name\n";
+ } catch (Throwable $e) {
+ ++$failures;
+ fwrite(STDERR, "FAIL: $name\n".$e."\n");
+ }
+}
+echo count($tests).' tests, '.$failures." failures\n";
+exit($failures ? 1 : 0);