mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-11 02:41:33 +00:00
[Web] API: complete the OpenAPI docs, add route tests for json_api.php
This commit is contained in:
47 files changed
+18848
-7491
No files matched your search
+14626
-7488
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes admin: administrator accounts */
|
||||
class AdminRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const DETAILS = ['username' => 'admin', 'tfa_active' => 0, 'tfa_active_int' => 0, 'active' => 1, 'active_int' => 1,
|
||||
'created' => '2026-09-21 10:23:08', 'attributes' => ['force_tfa' => '0', 'force_pw_update' => '0']];
|
||||
|
||||
public function testGetAllCollectsTheDetailsOfEveryAdmin(): void
|
||||
{
|
||||
$result = $this->get('get/admin/all', ['script' => [
|
||||
self::answer('admin', ['get'], ['admin', 'gone']),
|
||||
self::answer('admin', ['details', 'admin'], self::DETAILS),
|
||||
self::answer('admin', ['details', 'gone'], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame([self::DETAILS], $result->json());
|
||||
$this->assertSame([['get'], ['details', 'admin'], ['details', 'gone']], $result->callsTo('admin'));
|
||||
}
|
||||
|
||||
public function testGetAllWithoutAdmins(): void
|
||||
{
|
||||
$result = $this->get('get/admin/all', ['script' => [self::answer('admin', ['get'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
$this->assertSame([['get']], $result->callsTo('admin'));
|
||||
}
|
||||
|
||||
public function testGetOne(): void
|
||||
{
|
||||
$found = $this->get('get/admin/admin', ['script' => [self::answer('admin', ['details', 'admin'], self::DETAILS)]]);
|
||||
$this->assertSame(self::DETAILS, $found->json());
|
||||
|
||||
$missing = $this->get('get/admin/nobody', ['script' => [self::answer('admin', ['details'], false)]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
$this->assertCalled($missing, 'admin', ['details', 'nobody']);
|
||||
}
|
||||
|
||||
public function testAdd(): void
|
||||
{
|
||||
$attr = ['username' => 'admin2', 'password' => 'Rand0m-Passw0rd!', 'password2' => 'Rand0m-Passw0rd!', 'active' => 1];
|
||||
$messages = [self::message('success', ['admin_added', 'admin2'], ['admin', 'add', ['username' => 'admin2', 'password' => '*']])];
|
||||
|
||||
$result = $this->post('add/admin', $attr, ['script' => [self::answer('admin', ['add'], null, $messages)]]);
|
||||
|
||||
$this->assertSame([['add', $attr]], $result->callsTo('admin'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testEdit(): void
|
||||
{
|
||||
$result = $this->post('edit/admin', ['items' => ['admin2', 'admin3'], 'attr' => ['active' => '0', 'force_tfa' => 1]]);
|
||||
|
||||
$this->assertSame([['edit', ['username' => ['admin2', 'admin3'], 'active' => '0', 'force_tfa' => 1]]], $result->callsTo('admin'));
|
||||
}
|
||||
|
||||
public function testEditTakesASingleUsername(): void
|
||||
{
|
||||
$result = $this->post('edit/admin', ['items' => 'admin2', 'attr' => ['password' => 'x', 'password2' => 'x']]);
|
||||
|
||||
$this->assertCalled($result, 'admin', ['edit', ['username' => ['admin2'], 'password' => 'x', 'password2' => 'x']]);
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$messages = [
|
||||
self::message('success', ['admin_removed', 'admin2']),
|
||||
self::message('danger', ['username_invalid', 'nobody']),
|
||||
];
|
||||
|
||||
$result = $this->post('delete/admin', ['admin2', 'nobody'], ['script' => [self::answer('admin', ['delete'], null, $messages)]]);
|
||||
|
||||
$this->assertSame([['delete', ['username' => ['admin2', 'nobody']]]], $result->callsTo('admin'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes api-key: the ApiKey module */
|
||||
class ApiKeyRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const API = 'Mailcow\ApiKey\ApiKeyModule->api->';
|
||||
private const KEY = ['id' => 3, 'description' => 'provisioning', 'access' => 'rw', 'api_key' => '1B7E2C-94F0A3-6D5B18-C2E7F4-03A9D6',
|
||||
'allow_from' => ['192.0.2.10'], 'skip_ip_check' => 0, 'service' => 0, 'active' => 1, 'created' => '2026-11-02 09:12:44', 'modified' => null];
|
||||
|
||||
public function testGetAll(): void
|
||||
{
|
||||
$result = $this->get('get/api-key/all', ['script' => [self::answer(self::API . 'getKeys', [], [self::KEY])]]);
|
||||
|
||||
$this->assertSame([self::KEY], $result->json());
|
||||
$this->assertSame([[]], $result->callsTo(self::API . 'getKeys'));
|
||||
}
|
||||
|
||||
public function testGetAllAnswersAnEmptyObjectWithoutKeys(): void
|
||||
{
|
||||
// also the answer for read-only API keys: the module returns no keys for them
|
||||
$result = $this->get('get/api-key/all', ['session' => self::API_RO, 'script' => [self::answer(self::API . 'getKeys', [], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testGetOne(): void
|
||||
{
|
||||
$found = $this->get('get/api-key/3', ['script' => [self::answer(self::API . 'getKey', ['3'], self::KEY)]]);
|
||||
$this->assertSame(self::KEY, $found->json());
|
||||
|
||||
$missing = $this->get('get/api-key/99', ['script' => [self::answer(self::API . 'getKey', [], [])]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
$this->assertSame([['99']], $missing->callsTo(self::API . 'getKey'));
|
||||
}
|
||||
|
||||
public function testAdd(): void
|
||||
{
|
||||
$attr = ['description' => 'provisioning', 'access' => 'rw', 'allow_from' => '192.0.2.10, 2001:db8::/64', 'skip_ip_check' => 0, 'active' => 1];
|
||||
$messages = [self::message('success', ['api_key_added', 'provisioning'], ['api', 'add', 'key', $attr])];
|
||||
|
||||
$result = $this->post('add/api-key', $attr, ['script' => [self::answer(self::API . 'addKey', [], true, $messages)]]);
|
||||
|
||||
$this->assertSame([[$attr]], $result->callsTo(self::API . 'addKey'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testEdit(): void
|
||||
{
|
||||
$result = $this->post('edit/api-key', ['items' => [3, 4], 'attr' => ['regen_key' => 1]]);
|
||||
|
||||
$this->assertSame([[[3, 4], ['regen_key' => 1]]], $result->callsTo(self::API . 'editKeys'));
|
||||
}
|
||||
|
||||
public function testEditTakesASingleId(): void
|
||||
{
|
||||
$result = $this->post('edit/api-key', ['items' => 3, 'attr' => ['active' => 0]]);
|
||||
|
||||
$this->assertSame([[[3], ['active' => 0]]], $result->callsTo(self::API . 'editKeys'));
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$messages = [self::message('danger', ['api_key_service_locked', 'API_KEY'])];
|
||||
|
||||
$result = $this->post('delete/api-key', [1], ['script' => [self::answer(self::API . 'deleteKeys', [], true, $messages)]]);
|
||||
|
||||
$this->assertSame([[[1]]], $result->callsTo(self::API . 'deleteKeys'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes domain-admin, da-acl and sso/domain-admin */
|
||||
class DomainAdminRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const DETAILS = ['username' => 'domainadmin1', 'tfa_active' => 0, 'tfa_active_int' => 0, 'active' => 1, 'active_int' => 1,
|
||||
'created' => '2026-09-21 10:24:48', 'attributes' => ['force_tfa' => '0', 'force_pw_update' => '0'],
|
||||
'selected_domains' => ['example.org'], 'unselected_domains' => ['example.com']];
|
||||
|
||||
public function testGetAllCollectsTheDetailsOfEveryDomainAdmin(): void
|
||||
{
|
||||
$result = $this->get('get/domain-admin/all', ['script' => [
|
||||
self::answer('domain_admin', ['get'], ['domainadmin1', 'gone']),
|
||||
self::answer('domain_admin', ['details', 'domainadmin1'], self::DETAILS),
|
||||
self::answer('domain_admin', ['details', 'gone'], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame([self::DETAILS], $result->json());
|
||||
$this->assertSame([['get'], ['details', 'domainadmin1'], ['details', 'gone']], $result->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testGetAllWithoutDomainAdmins(): void
|
||||
{
|
||||
$result = $this->get('get/domain-admin/all', ['script' => [self::answer('domain_admin', ['get'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testGetOne(): void
|
||||
{
|
||||
$found = $this->get('get/domain-admin/domainadmin1', ['script' => [self::answer('domain_admin', ['details', 'domainadmin1'], self::DETAILS)]]);
|
||||
$this->assertSame(self::DETAILS, $found->json());
|
||||
|
||||
$missing = $this->get('get/domain-admin/nobody', ['script' => [self::answer('domain_admin', ['details'], false)]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
$this->assertSame([['details', 'nobody']], $missing->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testAdd(): void
|
||||
{
|
||||
$attr = ['username' => 'domainadmin1', 'password' => 'Rand0m-Passw0rd!', 'password2' => 'Rand0m-Passw0rd!',
|
||||
'domains' => ['example.org', 'example.com'], 'active' => 1];
|
||||
|
||||
$result = $this->post('add/domain-admin', $attr);
|
||||
|
||||
$this->assertSame([['add', $attr]], $result->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testEdit(): void
|
||||
{
|
||||
$result = $this->post('edit/domain-admin', ['items' => ['domainadmin1'], 'attr' => ['domains' => ['example.org'], 'disable_tfa' => 1]]);
|
||||
|
||||
$this->assertSame([['edit', ['username' => ['domainadmin1'], 'domains' => ['example.org'], 'disable_tfa' => 1]]], $result->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$result = $this->post('delete/domain-admin', ['domainadmin1', 'domainadmin2']);
|
||||
|
||||
$this->assertSame([['delete', ['username' => ['domainadmin1', 'domainadmin2']]]], $result->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testEditAcl(): void
|
||||
{
|
||||
$messages = [self::message('success', ['acl_saved', 'domainadmin1'])];
|
||||
|
||||
$result = $this->post('edit/da-acl', ['items' => 'domainadmin1', 'attr' => ['da_acl' => ['syncjobs', 'quarantine']]],
|
||||
['script' => [self::answer('acl', ['edit', 'domainadmin'], null, $messages)]]);
|
||||
|
||||
$this->assertSame([['edit', 'domainadmin', ['username' => ['domainadmin1'], 'da_acl' => ['syncjobs', 'quarantine']]]], $result->callsTo('acl'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testSsoTokenIsTheAnswer(): void
|
||||
{
|
||||
$token = ['token' => '591F6D-5C3DD2-7455CD-DAF1C1-AA4FCC'];
|
||||
|
||||
$result = $this->post('add/sso/domain-admin', ['username' => 'domainadmin1'],
|
||||
['script' => [self::answer('domain_admin_sso', ['issue'], $token)]]);
|
||||
|
||||
$this->assertSame([['issue', ['username' => 'domainadmin1']]], $result->callsTo('domain_admin_sso'));
|
||||
$this->assertSame($token, $result->json());
|
||||
}
|
||||
|
||||
public function testSsoTokenFailureAnswersTheMessages(): void
|
||||
{
|
||||
$messages = [self::message('danger', ['object_doesnt_exist', 'nobody'])];
|
||||
|
||||
$failed = $this->post('add/sso/domain-admin', ['username' => 'nobody'],
|
||||
['script' => [self::answer('domain_admin_sso', ['issue'], false, $messages)]]);
|
||||
$this->assertSame($messages, $failed->json());
|
||||
|
||||
$silent = $this->post('add/sso/domain-admin', ['username' => 'nobody'],
|
||||
['script' => [self::answer('domain_admin_sso', ['issue'], false)]]);
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot add item'], $silent->json());
|
||||
}
|
||||
|
||||
public function testSsoForOtherObjectsAnswersNothing(): void
|
||||
{
|
||||
$result = $this->post('add/sso/mailbox', ['username' => 'user@example.org']);
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
$this->assertNotCalled($result, 'domain_admin_sso');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/** Routes logs/<type>[/<count>] */
|
||||
class LogRoutesTest extends ApiTestCase
|
||||
{
|
||||
/** @return array<string, array{0: string, 1: string}> route type => log name of get_logs() */
|
||||
public static function logs(): array
|
||||
{
|
||||
return [
|
||||
'dovecot' => ['dovecot', 'dovecot-mailcow'],
|
||||
'ratelimited' => ['ratelimited', 'ratelimited'],
|
||||
'netfilter' => ['netfilter', 'netfilter-mailcow'],
|
||||
'cron' => ['cron', 'cron-mailcow'],
|
||||
'postfix' => ['postfix', 'postfix-mailcow'],
|
||||
'autodiscover' => ['autodiscover', 'autodiscover-mailcow'],
|
||||
'sogo' => ['sogo', 'sogo-mailcow'],
|
||||
'ui' => ['ui', 'mailcow-ui'],
|
||||
'sasl' => ['sasl', 'sasl'],
|
||||
'watchdog' => ['watchdog', 'watchdog-mailcow'],
|
||||
'acme' => ['acme', 'acme-mailcow'],
|
||||
'api' => ['api', 'api-mailcow'],
|
||||
'rspamd-history' => ['rspamd-history', 'rspamd-history'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('logs')]
|
||||
public function testWithoutCount(string $type, string $log): void
|
||||
{
|
||||
$entries = [['time' => '1794308452', 'message' => 'first'], ['time' => '1794308451', 'message' => 'second']];
|
||||
|
||||
$result = $this->get("get/logs/$type", ['script' => [self::answer('get_logs', [$log], $entries)]]);
|
||||
|
||||
$this->assertSame([[$log]], $result->callsTo('get_logs'));
|
||||
$this->assertSame($entries, $result->json());
|
||||
}
|
||||
|
||||
#[DataProvider('logs')]
|
||||
public function testCountOrRange(string $type, string $log): void
|
||||
{
|
||||
$this->assertSame([[$log, '50']], $this->get("get/logs/$type/50")->callsTo('get_logs'));
|
||||
$this->assertSame([[$log, '2-3']], $this->get("get/logs/$type/2-3")->callsTo('get_logs'));
|
||||
}
|
||||
|
||||
#[DataProvider('logs')]
|
||||
public function testEmptyLogs(string $type, string $log): void
|
||||
{
|
||||
$this->assertSame('{}', $this->get("get/logs/$type/5", ['script' => [self::answer('get_logs', [$log], false)]])->body);
|
||||
$this->assertSame('{}', $this->get("get/logs/$type", ['script' => [self::answer('get_logs', [$log], [])]])->body);
|
||||
}
|
||||
|
||||
public function testCountKeepsOnlyDigitsAndDashes(): void
|
||||
{
|
||||
$this->assertSame([['postfix-mailcow', '12-30']], $this->get('get/logs/postfix/a12-3x0')->callsTo('get_logs'));
|
||||
}
|
||||
|
||||
public function testTrailingSlashPassesAnEmptyCount(): void
|
||||
{
|
||||
$this->assertSame([['postfix-mailcow', '']], $this->get('get/logs/postfix/')->callsTo('get_logs'));
|
||||
}
|
||||
|
||||
public function testRspamdStatsIgnoresTheCount(): void
|
||||
{
|
||||
$stats = ['version' => '3.12.1', 'scanned' => 300, 'actions' => ['reject' => 38]];
|
||||
|
||||
$result = $this->get('get/logs/rspamd-stats/10', ['script' => [self::answer('get_logs', ['rspamd-stats'], $stats)]]);
|
||||
|
||||
$this->assertSame([['rspamd-stats']], $result->callsTo('get_logs'));
|
||||
$this->assertSame($stats, $result->json());
|
||||
}
|
||||
|
||||
public function testUnknownLog(): void
|
||||
{
|
||||
$this->assertApiError($this->get('get/logs/clamd'), 404, 'route not found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes oauth2-client */
|
||||
class OAuth2ClientRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const CLIENT = ['id' => 1, 'client_id' => '17c76aaa88c0', 'client_secret' => '73fc668a88147e32a31ff80c',
|
||||
'redirect_uri' => 'https://app.example.org/oauth/callback', 'grant_types' => null, 'scope' => 'profile', 'user_id' => null];
|
||||
|
||||
public function testGetAllCollectsTheDetailsOfEveryClient(): void
|
||||
{
|
||||
$result = $this->get('get/oauth2-client/all', ['script' => [
|
||||
self::answer('oauth2', ['get', 'clients'], [1, 2]),
|
||||
self::answer('oauth2', ['details', 'client', 1], self::CLIENT),
|
||||
self::answer('oauth2', ['details', 'client', 2], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame([self::CLIENT], $result->json());
|
||||
$this->assertSame([['get', 'clients'], ['details', 'client', 1], ['details', 'client', 2]], $result->callsTo('oauth2'));
|
||||
}
|
||||
|
||||
public function testGetAllWithoutClients(): void
|
||||
{
|
||||
$this->assertSame('{}', $this->get('get/oauth2-client/all', ['script' => [self::answer('oauth2', ['get', 'clients'], null)]])->body);
|
||||
}
|
||||
|
||||
public function testGetOne(): void
|
||||
{
|
||||
$found = $this->get('get/oauth2-client/1', ['script' => [self::answer('oauth2', ['details', 'client', '1'], self::CLIENT)]]);
|
||||
$this->assertSame(self::CLIENT, $found->json());
|
||||
|
||||
$missing = $this->get('get/oauth2-client/9', ['script' => [self::answer('oauth2', ['details'], false)]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
$this->assertSame([['details', 'client', '9']], $missing->callsTo('oauth2'));
|
||||
}
|
||||
|
||||
public function testAdd(): void
|
||||
{
|
||||
$result = $this->post('add/oauth2-client', ['redirect_uri' => 'https://app.example.org/oauth/callback']);
|
||||
|
||||
$this->assertSame([['add', 'client', ['redirect_uri' => 'https://app.example.org/oauth/callback']]], $result->callsTo('oauth2'));
|
||||
}
|
||||
|
||||
public function testEdit(): void
|
||||
{
|
||||
$result = $this->post('edit/oauth2-client', ['items' => [1, 2], 'attr' => ['renew_secret' => 1]]);
|
||||
|
||||
$this->assertSame([['edit', 'client', ['id' => [1, 2], 'renew_secret' => 1]]], $result->callsTo('oauth2'));
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$result = $this->post('delete/oauth2-client', [1, 2]);
|
||||
|
||||
$this->assertSame([['delete', 'client', ['id' => [1, 2]]]], $result->callsTo('oauth2'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes self and fido2-fn act on the account of the session; the pending password update of a login */
|
||||
class OwnAccountRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const PASSWORDS = ['user_old_pass' => 'old', 'user_new_pass' => 'N3w-Passw0rd!', 'user_new_pass2' => 'N3w-Passw0rd!'];
|
||||
|
||||
public function testSelfDoesNothingForAnApiKey(): void
|
||||
{
|
||||
$result = $this->post('edit/self', ['attr' => self::PASSWORDS]);
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
$this->assertNotCalled($result, 'domain_admin');
|
||||
$this->assertNotCalled($result, 'edit_user_account');
|
||||
}
|
||||
|
||||
public function testSelfOfADomainAdmin(): void
|
||||
{
|
||||
$result = $this->post('edit/self', ['attr' => self::PASSWORDS], ['session' => self::DOMAIN_ADMIN]);
|
||||
|
||||
$this->assertSame([['edit', self::PASSWORDS]], $result->callsTo('domain_admin'));
|
||||
}
|
||||
|
||||
public function testSelfOfAMailboxUser(): void
|
||||
{
|
||||
$result = $this->post('edit/self', ['attr' => self::PASSWORDS], ['session' => self::USER]);
|
||||
|
||||
$this->assertSame([[self::PASSWORDS]], $result->callsTo('edit_user_account'));
|
||||
}
|
||||
|
||||
public function testFido2FriendlyName(): void
|
||||
{
|
||||
$attr = ['fido2_cid' => hash('sha256', 'credential'), 'fido2_fn' => 'YubiKey'];
|
||||
|
||||
$result = $this->post('edit/fido2-fn', ['attr' => $attr]);
|
||||
|
||||
$this->assertSame([[['action' => 'edit_fn', 'fido2_attrs' => $attr]]], $result->callsTo('fido2'));
|
||||
}
|
||||
|
||||
public function testPendingPasswordUpdateAllowsOnlyThePasswordRoutes(): void
|
||||
{
|
||||
$session = self::ADMIN + ['pending_pw_update' => true];
|
||||
|
||||
$this->assertCalled($this->post('edit/admin', ['items' => 'admin', 'attr' => ['password' => 'x', 'password2' => 'x']], ['session' => $session]),
|
||||
'admin', ['edit', ['username' => ['admin'], 'password' => 'x', 'password2' => 'x']]);
|
||||
$this->assertCalled($this->get('get/passwordpolicy', ['session' => $session]), 'password_complexity', ['get']);
|
||||
|
||||
$denied = $this->post('edit/domain-admin', ['items' => 'domainadmin1', 'attr' => ['active' => 0]], ['session' => $session]);
|
||||
$this->assertApiError($denied, 403, 'API access denied until the pending account setup is finished');
|
||||
$this->assertNotCalled($denied, 'domain_admin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/** Routes passwordpolicy, reset-password-notification, the UI customization routes and cors */
|
||||
class SettingsRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const POLICY = ['length' => '8', 'chars' => '1', 'special_chars' => '0', 'lowerupper' => '1', 'numbers' => '1'];
|
||||
|
||||
public function testGetPasswordPolicy(): void
|
||||
{
|
||||
foreach (['get/passwordpolicy', 'get/passwordpolicy/all'] as $route) {
|
||||
$result = $this->get($route, ['script' => [self::answer('password_complexity', ['get'], self::POLICY)]]);
|
||||
$this->assertSame([['get']], $result->callsTo('password_complexity'), $route);
|
||||
$this->assertSame(self::POLICY, $result->json(), $route);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetPasswordPolicyAsHtml(): void
|
||||
{
|
||||
$html = '<p class="help-block small">- Minimum password length is 8</p>';
|
||||
|
||||
$result = $this->get('get/passwordpolicy/html', ['script' => [self::answer('password_complexity', ['html'], $html)]]);
|
||||
|
||||
$this->assertSame([['html']], $result->callsTo('password_complexity'));
|
||||
$this->assertSame($html, $result->json());
|
||||
}
|
||||
|
||||
public function testGetPasswordPolicyFailure(): void
|
||||
{
|
||||
$this->assertSame('{}', $this->get('get/passwordpolicy', ['script' => [self::answer('password_complexity', ['get'], false)]])->body);
|
||||
$this->assertSame('{}', $this->get('get/passwordpolicy/html', ['script' => [self::answer('password_complexity', ['html'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testEditPasswordPolicyIgnoresItems(): void
|
||||
{
|
||||
$result = $this->post('edit/passwordpolicy', ['items' => ['x'], 'attr' => ['length' => 12, 'numbers' => 1]]);
|
||||
|
||||
$this->assertSame([['edit', ['length' => 12, 'numbers' => 1]]], $result->callsTo('password_complexity'));
|
||||
}
|
||||
|
||||
public function testEditPasswordResetNotification(): void
|
||||
{
|
||||
$attr = ['from' => 'noreply@example.org', 'subject' => 'Reset your password', 'text_tmpl' => '{{link}}', 'html_tmpl' => ''];
|
||||
|
||||
$result = $this->post('edit/reset-password-notification', ['attr' => $attr]);
|
||||
|
||||
$this->assertSame([['edit_notification', $attr]], $result->callsTo('reset_password'));
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: string, 1: array}> */
|
||||
public static function customizations(): array
|
||||
{
|
||||
return [
|
||||
'ui_texts' => ['ui_texts', ['title_name' => 'Example Mail', 'ui_announcement_type' => 'warning', 'ui_announcement_active' => 1]],
|
||||
'ip_check' => ['ip_check', ['ip_check_opt_in' => '1']],
|
||||
'custom_login' => ['custom_login', ['hide_admin_quicklink' => 1, 'force_sso' => 0]],
|
||||
'ui_theme' => ['ui_theme', ['ui_theme' => 'servercow']],
|
||||
'app_links' => ['app_links', ['app' => ['Wiki'], 'href' => ['https://wiki.example.org'], 'user_href' => [''], 'hide' => ['0']]],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('customizations')]
|
||||
public function testCustomizationsPassAttrOnly(string $item, array $attr): void
|
||||
{
|
||||
$messages = [self::message('success', $item === 'ip_check' ? 'ip_check_opt_in_modified' : $item)];
|
||||
|
||||
$result = $this->post("edit/$item", ['items' => ['ignored'], 'attr' => $attr], ['script' => [self::answer('customize', ['edit', $item], null, $messages)]]);
|
||||
|
||||
$this->assertSame([['edit', $item, $attr]], $result->callsTo('customize'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testCustomizationWithoutAttrPassesNothing(): void
|
||||
{
|
||||
$result = $this->post('edit/app_links', ['items' => []]);
|
||||
|
||||
$this->assertSame([['edit', 'app_links', []]], $result->callsTo('customize'));
|
||||
}
|
||||
|
||||
public function testEditCors(): void
|
||||
{
|
||||
$attr = ['allowed_origins' => ['https://app.example.org'], 'allowed_methods' => ['GET', 'POST']];
|
||||
|
||||
$result = $this->post('edit/cors', ['attr' => $attr]);
|
||||
|
||||
$this->assertSame([['set_headers'], ['edit', $attr]], $result->callsTo('cors'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Admin;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes status/<object> */
|
||||
class StatusRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testContainersAreReducedToStateStartAndImage(): void
|
||||
{
|
||||
$info = [
|
||||
'dovecot-mailcow' => ['State' => ['Status' => 'running', 'StartedAt' => '2026-11-10T11:00:51.541059812Z', 'Pid' => 42],
|
||||
'Config' => ['Image' => 'ghcr.io/mailcow/dovecot:2.3.21.1-4', 'Hostname' => 'dovecot'], 'Id' => '5d0f3cbb2a6e'],
|
||||
'ofelia-mailcow' => ['State' => ['Status' => 'exited', 'StartedAt' => '2026-11-09T08:00:00Z'],
|
||||
'Config' => ['Image' => 'mcuadros/ofelia:latest'], 'Id' => '77aa01'],
|
||||
];
|
||||
|
||||
$result = $this->get('get/status/containers', ['script' => [self::answer('docker', ['info'], $info)]]);
|
||||
|
||||
$this->assertSame([['info']], $result->callsTo('docker'));
|
||||
$this->assertSame([
|
||||
'dovecot-mailcow' => ['type' => 'info', 'container' => 'dovecot-mailcow', 'state' => 'running',
|
||||
'started_at' => '2026-11-10T11:00:51.541059812Z', 'image' => 'ghcr.io/mailcow/dovecot:2.3.21.1-4'],
|
||||
'ofelia-mailcow' => ['type' => 'info', 'container' => 'ofelia-mailcow', 'state' => 'exited',
|
||||
'started_at' => '2026-11-09T08:00:00Z', 'image' => 'mcuadros/ofelia:latest'],
|
||||
], $result->json());
|
||||
$this->assertStringContainsString('"ghcr.io/mailcow/dovecot', $result->body);
|
||||
}
|
||||
|
||||
public function testContainersWithoutDockerapi(): void
|
||||
{
|
||||
$result = $this->get('get/status/containers', ['script' => [self::answer('docker', ['info'], 'Could not resolve host: dockerapi')]]);
|
||||
|
||||
$this->assertSame('null', $result->body);
|
||||
}
|
||||
|
||||
public function testContainerStatsByContainerId(): void
|
||||
{
|
||||
$stats = [['read' => '2026-11-10T19:20:01Z', 'pids_stats' => ['current' => 38]], ['read' => '2026-11-10T19:20:06Z', 'pids_stats' => ['current' => 39]]];
|
||||
|
||||
$result = $this->get('get/status/container/5d0f3cbb2a6e', ['script' => [self::answer('docker', ['container_stats'], $stats)]]);
|
||||
|
||||
$this->assertSame([['container_stats', '5d0f3cbb2a6e']], $result->callsTo('docker'));
|
||||
$this->assertSame($stats, $result->json());
|
||||
}
|
||||
|
||||
public function testContainerStatsWithoutId(): void
|
||||
{
|
||||
$result = $this->get('get/status/container', ['script' => [self::answer('docker', ['container_stats'], false)]]);
|
||||
|
||||
$this->assertSame([['container_stats', null]], $result->callsTo('docker'));
|
||||
$this->assertSame('false', $result->body);
|
||||
}
|
||||
|
||||
public function testVmailSplitsTheDfLine(): void
|
||||
{
|
||||
$df = json_encode('/dev/sda1,105G,65G,40G,66%,/var/vmail');
|
||||
|
||||
$result = $this->get('get/status/vmail', ['script' => [self::answer('docker', ['post', 'dovecot-mailcow', 'exec'], $df)]]);
|
||||
|
||||
$this->assertSame([['post', 'dovecot-mailcow', 'exec', ['cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail']]], $result->callsTo('docker'));
|
||||
$this->assertSame(['type' => 'info', 'disk' => '/dev/sda1', 'used' => '65G', 'total' => '105G', 'used_percent' => '66%'], $result->json());
|
||||
}
|
||||
|
||||
public function testHost(): void
|
||||
{
|
||||
$stats = ['cpu' => ['cores' => 4, 'usage' => 6], 'memory' => ['total' => 12541669376, 'usage' => 62.5], 'uptime' => 703800.5,
|
||||
'system_time' => '10.11.2026 19:13:07', 'architecture' => 'x86_64'];
|
||||
|
||||
$result = $this->get('get/status/host', ['script' => [self::answer('docker', ['host_stats'], $stats)]]);
|
||||
|
||||
$this->assertSame([['host_stats']], $result->callsTo('docker'));
|
||||
$this->assertSame($stats, $result->json());
|
||||
}
|
||||
|
||||
public function testPublicAddressesAskMailcowEmail(): void
|
||||
{
|
||||
$result = $this->get('get/status/host/ip', ['script' => [
|
||||
self::answer('curl_exec', [], '198.51.100.7', once: true),
|
||||
self::answer('curl_exec', [], '2001:db8::7', once: true),
|
||||
]]);
|
||||
|
||||
$this->assertSame([[null, CURLOPT_URL, 'http://ipv4.mailcow.email'], [null, CURLOPT_URL, 'http://ipv6.mailcow.email']],
|
||||
array_values(array_filter($result->callsTo('curl_setopt'), fn(array $args): bool => $args[1] === CURLOPT_URL)));
|
||||
$this->assertSame(['ipv4' => '198.51.100.7', 'ipv6' => '2001:db8::7'], $result->json());
|
||||
}
|
||||
|
||||
public function testHostWithAnotherSegmentAnswersNothing(): void
|
||||
{
|
||||
$result = $this->get('get/status/host/cpu');
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
$this->assertNotCalled($result, 'docker');
|
||||
}
|
||||
|
||||
public function testVersion(): void
|
||||
{
|
||||
// $MAILCOW_GIT_VERSION comes from app_info.inc.php, which the harness does not load
|
||||
$this->assertSame(['version' => null], $this->get('get/status/version')->json());
|
||||
}
|
||||
|
||||
public function testUnknownStatusAnswersNothing(): void
|
||||
{
|
||||
$result = $this->get('get/status/disks');
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
}
|
||||
|
||||
public function testOnlyForAdmins(): void
|
||||
{
|
||||
foreach (['containers', 'vmail', 'host', 'version'] as $object) {
|
||||
$result = $this->get("get/status/$object", ['session' => self::DOMAIN_ADMIN]);
|
||||
$this->assertSame([200, ''], [$result->status, $result->body], $object);
|
||||
$this->assertNotCalled($result, 'docker');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api;
|
||||
|
||||
/** What json_api.php did for one request of ApiTestCase: status, output and the calls it made */
|
||||
final class ApiResult
|
||||
{
|
||||
/**
|
||||
* @param list<array{0: string, 1: array}> $calls function or method name and arguments, in call order
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $status,
|
||||
public readonly string $body,
|
||||
public readonly array $calls,
|
||||
public readonly ?array $sessionReturn,
|
||||
public readonly ?string $error
|
||||
) {
|
||||
}
|
||||
|
||||
/** The decoded output, null if it is no JSON */
|
||||
public function json(): mixed
|
||||
{
|
||||
return json_decode($this->body, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments of every call of $name, e.g. 'mailbox' or 'Mailcow\ApiKey\ApiKeyModule->api->addKey'
|
||||
*
|
||||
* @return list<array>
|
||||
*/
|
||||
public function callsTo(string $name): array
|
||||
{
|
||||
return array_values(array_map(fn(array $call): array => $call[1], array_filter($this->calls, fn(array $call): bool => $call[0] === $name)));
|
||||
}
|
||||
|
||||
/** Names of the calls, without the API log of every request (redis->lPush) */
|
||||
public function callNames(): array
|
||||
{
|
||||
return array_values(array_filter(array_column($this->calls, 0), fn(string $name): bool => $name !== 'redis->lPush'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Runs requests through the real data/web/json_api.php in a process of its own (Harness/runner.php). The legacy
|
||||
* functions (mailbox(), bcc(), ...) and the module classes it calls are recorders: a test checks the HTTP behaviour of
|
||||
* a route and which backend call it makes with which arguments, not the backend itself.
|
||||
*
|
||||
* $result = $this->post('add/alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']);
|
||||
* $this->assertCalled($result, 'mailbox', ['add', 'alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']]);
|
||||
*
|
||||
* Backend answers are scripted with self::answer(); a call without one returns null (module chains like
|
||||
* Module::fromGlobals()->api->x() resolve to recorders).
|
||||
*/
|
||||
abstract class ApiTestCase extends TestCase
|
||||
{
|
||||
/** Session of a read/write API key (sessions.inc.php logs it in as the admin "API") */
|
||||
protected const API_RW = ['mailcow_cc_username' => 'API', 'mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'rw'];
|
||||
/** Session of a read-only API key */
|
||||
protected const API_RO = ['mailcow_cc_username' => 'API', 'mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'ro'];
|
||||
/** UI sessions */
|
||||
protected const ADMIN = ['mailcow_cc_username' => 'admin', 'mailcow_cc_role' => 'admin'];
|
||||
protected const DOMAIN_ADMIN = ['mailcow_cc_username' => 'domainadmin', 'mailcow_cc_role' => 'domainadmin'];
|
||||
protected const USER = ['mailcow_cc_username' => 'user@example.org', 'mailcow_cc_role' => 'user'];
|
||||
protected const NO_SESSION = [];
|
||||
|
||||
private const JSON_API = __DIR__ . '/../../../data/web/json_api.php';
|
||||
/** PHP functions with outside effects that json_api.php calls directly: disabled, so the harness records them */
|
||||
private const DISABLED = ['curl_init', 'curl_setopt', 'curl_setopt_array', 'curl_exec', 'curl_close', 'curl_error', 'curl_errno', 'curl_getinfo'];
|
||||
|
||||
/**
|
||||
* One scripted backend answer: the first call of $call whose arguments start with $args returns $return and
|
||||
* appends $messages to $_SESSION['return'] (what the legacy functions do), $once makes it answer one call only.
|
||||
*/
|
||||
protected static function answer(string $call, array $args = [], mixed $return = null, array $messages = [], bool $once = false): array
|
||||
{
|
||||
return ['call' => $call, 'args' => $args, 'return' => $return, 'messages' => $messages, 'once' => $once];
|
||||
}
|
||||
|
||||
/** A mailcow message as the legacy functions put it into $_SESSION['return'] */
|
||||
protected static function message(string $type, string|array $msg, array $log = []): array
|
||||
{
|
||||
return ['type' => $type, 'log' => $log, 'msg' => $msg];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{session?: array, script?: list<array>, server?: array, query?: array} $options
|
||||
*/
|
||||
protected function get(string $route, array $options = []): ApiResult
|
||||
{
|
||||
return $this->request('GET', $route, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST with a JSON body (any value; a string is sent as it is), as API clients send it
|
||||
*
|
||||
* @param array{session?: array, script?: list<array>, server?: array, query?: array, content_type?: string} $options
|
||||
*/
|
||||
protected function post(string $route, mixed $json = null, array $options = []): ApiResult
|
||||
{
|
||||
if ($json !== null) {
|
||||
$options['body'] = is_string($json) ? $json : json_encode($json);
|
||||
$options['content_type'] ??= 'application/json';
|
||||
}
|
||||
return $this->request('POST', $route, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST with form fields (attr, items as JSON strings), as the mailcow UI sends it
|
||||
*
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
protected function postForm(string $route, array $fields, array $options = []): ApiResult
|
||||
{
|
||||
$options['post'] = $fields;
|
||||
$options['content_type'] ??= 'application/x-www-form-urlencoded';
|
||||
return $this->request('POST', $route, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $route the part after /api/v1/, e.g. "get/domain/all"
|
||||
* @param array{session?: array, script?: list<array>, server?: array, query?: array, post?: array, body?: string,
|
||||
* content_type?: string, globals?: array, allow_error?: bool} $options globals: variables prerequisites.inc.php
|
||||
* would set (e.g. iam_settings)
|
||||
*/
|
||||
protected function request(string $method, string $route, array $options = []): ApiResult
|
||||
{
|
||||
$dir = sys_get_temp_dir();
|
||||
$requestFile = tempnam($dir, 'api-req-');
|
||||
$resultFile = tempnam($dir, 'api-res-');
|
||||
try {
|
||||
file_put_contents($requestFile, json_encode([
|
||||
'json_api' => realpath(self::JSON_API),
|
||||
'method' => $method,
|
||||
'query' => ltrim(preg_replace('#^/?api/v1/#', '', $route), '/'),
|
||||
'get' => $options['query'] ?? [],
|
||||
'post' => $options['post'] ?? [],
|
||||
'body' => $options['body'] ?? null,
|
||||
'content_type' => $options['content_type'] ?? null,
|
||||
'server' => $options['server'] ?? [],
|
||||
'session' => $options['session'] ?? self::API_RW,
|
||||
'script' => $options['script'] ?? [],
|
||||
'globals' => $options['globals'] ?? [],
|
||||
'result' => $resultFile,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$process = proc_open([PHP_BINARY, '-d', 'display_errors=stderr', '-d', 'disable_functions=' . implode(',', self::DISABLED),
|
||||
__DIR__ . '/Harness/runner.php', $requestFile],
|
||||
[0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($process);
|
||||
$data = json_decode((string)file_get_contents($resultFile), true);
|
||||
$this->assertIsArray($data, "the harness wrote no result for $method $route:\n$stdout$stderr");
|
||||
} finally {
|
||||
@unlink($requestFile);
|
||||
@unlink($resultFile);
|
||||
}
|
||||
$result = new ApiResult((int)$data['status'], (string)$data['body'], $data['calls'], $data['session_return'], $data['error']);
|
||||
if (empty($options['allow_error'])) {
|
||||
$this->assertNull($result->error, "json_api.php failed for $method $route");
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** At least one call of $name had exactly $args */
|
||||
protected function assertCalled(ApiResult $result, string $name, array $args): void
|
||||
{
|
||||
$this->assertContains($args, $result->callsTo($name),
|
||||
"no call $name(" . json_encode($args) . "), calls were:\n" . json_encode($result->calls, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/** $name was not called at all */
|
||||
protected function assertNotCalled(ApiResult $result, string $name): void
|
||||
{
|
||||
$this->assertSame([], $result->callsTo($name), "unexpected call of $name");
|
||||
}
|
||||
|
||||
/** The JSON answer json_api.php gives without calling a backend, e.g. 404 route not found */
|
||||
protected function assertApiError(ApiResult $result, int $status, string $msg): void
|
||||
{
|
||||
$this->assertSame([$status, ['type' => 'error', 'msg' => $msg]], [$result->status, $result->json()], $result->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/add/edit/delete alias-domain */
|
||||
class AliasDomainTest extends ApiTestCase
|
||||
{
|
||||
public function testAllAliasDomainsAreReadOneByOne(): void
|
||||
{
|
||||
$result = $this->get('get/alias-domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'alias_domains'], ['example.net', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'alias_domain_details', 'example.net'], ['alias_domain' => 'example.net', 'target_domain' => 'example.org']),
|
||||
self::answer('mailbox', ['get', 'alias_domain_details', 'example.com'], false),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'alias_domains']);
|
||||
$this->assertSame([['alias_domain' => 'example.net', 'target_domain' => 'example.org']], $result->json());
|
||||
}
|
||||
|
||||
public function testAllAliasDomainsWithoutAliasDomains(): void
|
||||
{
|
||||
$result = $this->get('get/alias-domain/all', ['script' => [self::answer('mailbox', ['get', 'alias_domains'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
$this->assertSame([['get', 'alias_domains']], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testOneAliasDomain(): void
|
||||
{
|
||||
$result = $this->get('get/alias-domain/example.net', ['script' => [
|
||||
self::answer('mailbox', ['get', 'alias_domain_details', 'example.net'], ['alias_domain' => 'example.net', 'rl' => false]),
|
||||
]]);
|
||||
|
||||
$this->assertSame(['alias_domain' => 'example.net', 'rl' => false], $result->json());
|
||||
}
|
||||
|
||||
public function testUnknownAliasDomain(): void
|
||||
{
|
||||
$result = $this->get('get/alias-domain/example.com', ['script' => [self::answer('mailbox', ['get', 'alias_domain_details'], false)]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'alias_domain_details', 'example.com']);
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testAddAliasDomains(): void
|
||||
{
|
||||
$attr = ['alias_domain' => "example.net\nexample.com", 'target_domain' => 'example.org', 'active' => '1', 'key_size' => '2048', 'dkim_selector' => 'dkim'];
|
||||
|
||||
$result = $this->post('add/alias-domain', $attr);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['add', 'alias_domain', $attr]);
|
||||
}
|
||||
|
||||
public function testEditAliasDomains(): void
|
||||
{
|
||||
$result = $this->post('edit/alias-domain', ['items' => ['example.net', 'example.com'], 'attr' => ['active' => '0']]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'alias_domain', ['alias_domain' => ['example.net', 'example.com'], 'active' => '0']]);
|
||||
}
|
||||
|
||||
public function testDeleteAliasDomains(): void
|
||||
{
|
||||
$result = $this->post('delete/alias-domain', ['example.net']);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'alias_domain', ['alias_domain' => ['example.net']]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/dkim, add/dkim, add/dkim_duplicate, add/dkim_import, delete/dkim */
|
||||
class DkimTest extends ApiTestCase
|
||||
{
|
||||
public function testDkimKeyOfADomain(): void
|
||||
{
|
||||
$key = ['pubkey' => 'MIIBIjAN', 'length' => '2048', 'dkim_txt' => 'v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjAN', 'dkim_selector' => 'dkim', 'privkey' => ''];
|
||||
|
||||
$result = $this->get('get/dkim/example.org', ['script' => [self::answer('dkim', ['details', 'example.org'], $key)]]);
|
||||
|
||||
$this->assertSame([['details', 'example.org']], $result->callsTo('dkim'));
|
||||
$this->assertSame($key, $result->json());
|
||||
}
|
||||
|
||||
public function testDomainWithoutKey(): void
|
||||
{
|
||||
$result = $this->get('get/dkim/example.com', ['script' => [self::answer('dkim', ['details'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testGenerateKeys(): void
|
||||
{
|
||||
$attr = ['domains' => 'example.org, example.net', 'dkim_selector' => 'dkim', 'key_size' => 2048];
|
||||
|
||||
$result = $this->post('add/dkim', $attr);
|
||||
|
||||
$this->assertSame([['add', $attr]], $result->callsTo('dkim'));
|
||||
}
|
||||
|
||||
public function testDuplicateKey(): void
|
||||
{
|
||||
$attr = ['from_domain' => 'example.org', 'to_domain' => ['example.net', 'example.com']];
|
||||
|
||||
$result = $this->post('add/dkim_duplicate', $attr);
|
||||
|
||||
$this->assertSame([['duplicate', $attr]], $result->callsTo('dkim'));
|
||||
}
|
||||
|
||||
public function testImportKey(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'dkim_selector' => 'dkim2026', 'private_key_file' => "-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----", 'overwrite_existing' => '1'];
|
||||
|
||||
$result = $this->post('add/dkim_import', $attr);
|
||||
|
||||
$this->assertSame([['import', $attr]], $result->callsTo('dkim'));
|
||||
}
|
||||
|
||||
public function testDeleteKeys(): void
|
||||
{
|
||||
$result = $this->post('delete/dkim', ['example.org', 'example.net']);
|
||||
|
||||
$this->assertSame([['delete', ['domains' => ['example.org', 'example.net']]]], $result->callsTo('dkim'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/policy_wl_domain, get/policy_bl_domain, add/domain-policy, delete/domain-policy */
|
||||
class DomainPolicyTest extends ApiTestCase
|
||||
{
|
||||
private const LISTS = [
|
||||
'whitelist' => [['object' => 'example.org', 'value' => '*@partner.example.com', 'prefid' => 12]],
|
||||
'blacklist' => [['object' => 'anna@example.org', 'value' => '*@spam.example.com', 'prefid' => 13]],
|
||||
];
|
||||
|
||||
public function testAllowListOfADomain(): void
|
||||
{
|
||||
$result = $this->get('get/policy_wl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain', 'example.org'], self::LISTS)]]);
|
||||
|
||||
$this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('policy'));
|
||||
$this->assertSame(self::LISTS['whitelist'], $result->json());
|
||||
}
|
||||
|
||||
public function testDenyListOfADomain(): void
|
||||
{
|
||||
$result = $this->get('get/policy_bl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain', 'example.org'], self::LISTS)]]);
|
||||
|
||||
$this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('policy'));
|
||||
$this->assertSame(self::LISTS['blacklist'], $result->json());
|
||||
}
|
||||
|
||||
public function testEmptyListsAndUnknownDomains(): void
|
||||
{
|
||||
$empty = $this->get('get/policy_wl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain'], ['whitelist' => [], 'blacklist' => []])]]);
|
||||
$this->assertSame('{}', $empty->body);
|
||||
|
||||
$unknown = $this->get('get/policy_bl_domain/example.com', ['script' => [self::answer('policy', ['get', 'domain'], false)]]);
|
||||
$this->assertSame('{}', $unknown->body);
|
||||
}
|
||||
|
||||
public function testAddEntry(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'object_list' => 'wl', 'object_from' => '*@partner.example.com'];
|
||||
|
||||
$result = $this->post('add/domain-policy', $attr);
|
||||
|
||||
$this->assertSame([['add', 'domain', $attr]], $result->callsTo('policy'));
|
||||
}
|
||||
|
||||
public function testDeleteEntries(): void
|
||||
{
|
||||
$result = $this->post('delete/domain-policy', [12, 13]);
|
||||
|
||||
$this->assertSame([['delete', 'domain', ['prefid' => [12, 13]]]], $result->callsTo('policy'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/rl-domain, edit/rl-domain */
|
||||
class DomainRatelimitTest extends ApiTestCase
|
||||
{
|
||||
public function testRatelimitOfADomain(): void
|
||||
{
|
||||
$result = $this->get('get/rl-domain/example.org', ['script' => [self::answer('ratelimit', ['get', 'domain', 'example.org'], ['value' => '100', 'frame' => 'h'])]]);
|
||||
|
||||
$this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('ratelimit'));
|
||||
$this->assertSame(['value' => '100', 'frame' => 'h'], $result->json());
|
||||
}
|
||||
|
||||
public function testDomainWithoutRatelimit(): void
|
||||
{
|
||||
$result = $this->get('get/rl-domain/example.org', ['script' => [self::answer('ratelimit', ['get', 'domain'], false)]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testAllRatelimitsOfDomainsAndAliasDomains(): void
|
||||
{
|
||||
$result = $this->get('get/rl-domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], ['example.net']),
|
||||
self::answer('ratelimit', ['get', 'domain', 'example.org'], ['value' => '100', 'frame' => 'h']),
|
||||
self::answer('ratelimit', ['get', 'domain', 'example.com'], false),
|
||||
self::answer('ratelimit', ['get', 'domain', 'example.net'], ['value' => '10', 'frame' => 's']),
|
||||
]]);
|
||||
|
||||
$this->assertSame([['get', 'domains'], ['get', 'alias_domains']], $result->callsTo('mailbox'));
|
||||
$this->assertSame([['get', 'domain', 'example.org'], ['get', 'domain', 'example.com'], ['get', 'domain', 'example.net']], $result->callsTo('ratelimit'));
|
||||
$this->assertSame([
|
||||
['value' => '100', 'frame' => 'h', 'domain' => 'example.org'],
|
||||
['value' => '10', 'frame' => 's', 'domain' => 'example.net'],
|
||||
], $result->json());
|
||||
}
|
||||
|
||||
public function testAllWithoutRatelimits(): void
|
||||
{
|
||||
$none = $this->get('get/rl-domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], []),
|
||||
self::answer('ratelimit', ['get', 'domain'], false),
|
||||
]]);
|
||||
$this->assertSame('{}', $none->body);
|
||||
|
||||
$noDomains = $this->get('get/rl-domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], []),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], []),
|
||||
]]);
|
||||
$this->assertSame('{}', $noDomains->body);
|
||||
$this->assertNotCalled($noDomains, 'ratelimit');
|
||||
}
|
||||
|
||||
public function testSetRatelimits(): void
|
||||
{
|
||||
$result = $this->post('edit/rl-domain', ['items' => ['example.org', 'example.net'], 'attr' => ['rl_value' => 100, 'rl_frame' => 'h']]);
|
||||
|
||||
$this->assertSame([['edit', 'domain', ['object' => ['example.org', 'example.net'], 'rl_value' => 100, 'rl_frame' => 'h']]], $result->callsTo('ratelimit'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiResult;
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** search/domain: DataTables server-side processing (SSP) of the domain table */
|
||||
class DomainSearchTest extends ApiTestCase
|
||||
{
|
||||
private const REQUEST = [
|
||||
'draw' => 3,
|
||||
'start' => 0,
|
||||
'length' => 10,
|
||||
'search' => ['value' => 'example'],
|
||||
'order' => [['column' => 2, 'dir' => 'asc']],
|
||||
'columns' => [
|
||||
['data' => '', 'searchable' => false, 'orderable' => false],
|
||||
['data' => 'chkbox', 'searchable' => false, 'orderable' => false],
|
||||
['data' => 'domain_h_name', 'searchable' => true, 'orderable' => true, 'search' => ['value' => '']],
|
||||
],
|
||||
];
|
||||
|
||||
private const PAGE = ['draw' => 3, 'recordsTotal' => 12, 'recordsFiltered' => 2, 'data' => [[2 => 'example.org'], [2 => 'example.com']]];
|
||||
|
||||
public function testAdminsSearchAllDomains(): void
|
||||
{
|
||||
$result = $this->post('search/domain', self::REQUEST, ['script' => [self::answer('SSP::simple', [], self::PAGE)]]);
|
||||
|
||||
$call = $this->sspCall($result, 'SSP::simple');
|
||||
$this->assertSame([self::REQUEST, '@pdo', ['domain', 'd'], 'domain'], array_slice($call, 0, 4));
|
||||
$this->assertSame([
|
||||
2 => 'domain', 3 => 'aliases', 4 => 'mailboxes', 5 => 'quota', 6 => 'stats', 7 => 'defquota', 8 => 'maxquota',
|
||||
10 => 'backupmx', 14 => 'tags', 15 => 'active',
|
||||
], array_column($call[4], 'db', 'dt'));
|
||||
$this->assertNotCalled($result, 'SSP::complex');
|
||||
}
|
||||
|
||||
public function testRowsAreReplacedByTheDomainDetails(): void
|
||||
{
|
||||
$result = $this->post('search/domain', self::REQUEST, ['script' => [
|
||||
self::answer('SSP::simple', [], self::PAGE),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org']),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.com'], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame([['get', 'domain_details', 'example.org'], ['get', 'domain_details', 'example.com']], $result->callsTo('mailbox'));
|
||||
$this->assertSame(['draw' => 3, 'recordsTotal' => 12, 'recordsFiltered' => 2, 'data' => [['domain_name' => 'example.org']]], $result->json());
|
||||
}
|
||||
|
||||
public function testAnEmptyPageIsAnsweredAsItIs(): void
|
||||
{
|
||||
$page = ['draw' => 1, 'recordsTotal' => 12, 'recordsFiltered' => 0, 'data' => []];
|
||||
|
||||
$result = $this->post('search/domain', ['draw' => 1], ['script' => [self::answer('SSP::simple', [], $page)]]);
|
||||
|
||||
$this->assertSame($page, $result->json());
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
}
|
||||
|
||||
public function testErrorsOfTheSearchArePassedOn(): void
|
||||
{
|
||||
$result = $this->post('search/domain', self::REQUEST, ['script' => [self::answer('SSP::simple', [], ['error' => 'An SQL error occurred: unknown column'])]]);
|
||||
|
||||
$this->assertSame(['error' => 'An SQL error occurred: unknown column'], $result->json());
|
||||
}
|
||||
|
||||
public function testReadOnlyKeysCanSearch(): void
|
||||
{
|
||||
$result = $this->post('search/domain', self::REQUEST, ['session' => self::API_RO, 'script' => [self::answer('SSP::simple', [], self::PAGE)]]);
|
||||
|
||||
$this->assertSame(200, $result->status);
|
||||
$this->assertSame(self::REQUEST, $this->sspCall($result, 'SSP::simple')[0]);
|
||||
}
|
||||
|
||||
public function testDomainAdminsSearchTheirDomains(): void
|
||||
{
|
||||
$result = $this->post('search/domain', self::REQUEST, ['session' => self::DOMAIN_ADMIN, 'script' => [self::answer('SSP::complex', [], self::PAGE)]]);
|
||||
|
||||
$call = $this->sspCall($result, 'SSP::complex');
|
||||
$this->assertSame([self::REQUEST, '@pdo', ['domain', 'd'], 'domain'], array_slice($call, 0, 4));
|
||||
$this->assertSame(['INNER JOIN domain_admins as da ON da.domain = d.domain', [
|
||||
'condition' => 'da.active = 1 and da.username = :username',
|
||||
'bindings' => ['username' => 'domainadmin'],
|
||||
]], array_slice($call, 5));
|
||||
$this->assertNotCalled($result, 'SSP::simple');
|
||||
}
|
||||
|
||||
public function testOtherLoginsAreRefused(): void
|
||||
{
|
||||
foreach ([self::USER, self::NO_SESSION] as $session) {
|
||||
$result = $this->post('search/domain', self::REQUEST, ['session' => $session]);
|
||||
|
||||
$this->assertApiError($result, 403, 'Insufficient permissions');
|
||||
$this->assertNotCalled($result, 'SSP::simple');
|
||||
$this->assertNotCalled($result, 'SSP::complex');
|
||||
}
|
||||
}
|
||||
|
||||
public function testWithoutJsonBodyTheSearchGetsNoParameters(): void
|
||||
{
|
||||
$result = $this->request('POST', 'search/domain', ['script' => [self::answer('SSP::simple', [], self::PAGE)]]);
|
||||
|
||||
$this->assertNull($this->sspCall($result, 'SSP::simple')[0]);
|
||||
}
|
||||
|
||||
private function sspCall(ApiResult $result, string $name): array
|
||||
{
|
||||
$calls = $result->callsTo($name);
|
||||
$this->assertCount(1, $calls, "one call of $name");
|
||||
return $calls[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/add/edit/delete domain/template */
|
||||
class DomainTemplateTest extends ApiTestCase
|
||||
{
|
||||
private const TEMPLATE = ['id' => 1, 'template' => 'Default', 'type' => 'domain', 'attributes' => ['max_num_aliases_for_domain' => 400]];
|
||||
|
||||
public function testAllTemplates(): void
|
||||
{
|
||||
$result = $this->get('get/domain/template/all', ['script' => [self::answer('mailbox', ['get', 'domain_templates'], [self::TEMPLATE])]]);
|
||||
|
||||
$this->assertSame([['get', 'domain_templates']], $result->callsTo('mailbox'));
|
||||
$this->assertSame([self::TEMPLATE], $result->json());
|
||||
}
|
||||
|
||||
public function testOneTemplate(): void
|
||||
{
|
||||
$result = $this->get('get/domain/template/1', ['script' => [self::answer('mailbox', ['get', 'domain_templates', '1'], self::TEMPLATE)]]);
|
||||
|
||||
$this->assertSame(self::TEMPLATE, $result->json());
|
||||
}
|
||||
|
||||
public function testUnknownTemplate(): void
|
||||
{
|
||||
$result = $this->get('get/domain/template/9', ['script' => [self::answer('mailbox', ['get', 'domain_templates'], false)]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'domain_templates', '9']);
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testTemplateWithoutIdAsksForAll(): void
|
||||
{
|
||||
$result = $this->get('get/domain/template');
|
||||
|
||||
$this->assertSame([['get', 'domain_templates', null]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testAddTemplate(): void
|
||||
{
|
||||
$attr = ['template' => 'Small business', 'max_num_mboxes_for_domain' => 25, 'def_quota_for_mbox' => 2048, 'tags' => ['small']];
|
||||
|
||||
$result = $this->post('add/domain/template', $attr);
|
||||
|
||||
$this->assertSame([['add', 'domain_templates', $attr]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testEditTemplates(): void
|
||||
{
|
||||
$result = $this->post('edit/domain/template', ['items' => [2, 3], 'attr' => ['template' => 'Small business', 'active' => 1]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'domain_templates', ['ids' => [2, 3], 'template' => 'Small business', 'active' => 1]]);
|
||||
}
|
||||
|
||||
public function testDeleteTemplates(): void
|
||||
{
|
||||
$result = $this->post('delete/domain/template', [2, 3]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'domain_templates', ['ids' => [2, 3]]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** get/domain, add/domain, edit/domain, delete/domain, delete/domain/tag, edit/domain/footer */
|
||||
class DomainTest extends ApiTestCase
|
||||
{
|
||||
public function testAllDomainsAreReadOneByOne(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com', 'example.net']),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org']),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.com'], false),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.net'], ['domain_name' => 'example.net']),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'domains', null, null]);
|
||||
$this->assertSame([['domain_name' => 'example.org'], ['domain_name' => 'example.net']], $result->json());
|
||||
}
|
||||
|
||||
public function testAllDomainsFilteredByTags(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['query' => ['tags' => 'customer,premium'], 'script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org', 'tags' => ['customer']]),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'domains', null, ['customer', 'premium']]);
|
||||
$this->assertSame([['domain_name' => 'example.org', 'tags' => ['customer']]], $result->json());
|
||||
}
|
||||
|
||||
public function testAnEmptyTagsParameterDoesNotFilter(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['query' => ['tags' => ''], 'script' => [self::answer('mailbox', ['get', 'domains'], [])]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'domains', null, null]);
|
||||
}
|
||||
|
||||
public function testAllDomainsWithoutDomains(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['script' => [self::answer('mailbox', ['get', 'domains'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
$this->assertSame([['get', 'domains', null, null]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testAllDomainsWithoutReadableDetails(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'domain_details'], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testOneDomain(): void
|
||||
{
|
||||
$result = $this->get('get/domain/example.org', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org', 'rl' => false]),
|
||||
]]);
|
||||
|
||||
$this->assertSame(['domain_name' => 'example.org', 'rl' => false], $result->json());
|
||||
$this->assertSame([['get', 'domain_details', 'example.org']], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testUnknownDomain(): void
|
||||
{
|
||||
$result = $this->get('get/domain/example.com', ['script' => [self::answer('mailbox', ['get', 'domain_details'], false)]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testAddDomain(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'description' => 'Example Org', 'defquota' => 3072, 'tags' => ['customer'], 'restart_sogo' => '1'];
|
||||
|
||||
$result = $this->post('add/domain', $attr);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['add', 'domain', $attr]);
|
||||
}
|
||||
|
||||
public function testAddDomainIgnoresFurtherSegments(): void
|
||||
{
|
||||
$result = $this->post('add/domain/example.org', ['domain' => 'example.org']);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['add', 'domain', ['domain' => 'example.org']]);
|
||||
}
|
||||
|
||||
public function testEditDomains(): void
|
||||
{
|
||||
$result = $this->post('edit/domain', ['items' => ['example.org', 'example.com'], 'attr' => ['quota' => 51200, 'tags' => ['premium']]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => ['example.org', 'example.com'], 'quota' => 51200, 'tags' => ['premium']]]);
|
||||
}
|
||||
|
||||
public function testEditDomainWithASingleItem(): void
|
||||
{
|
||||
$result = $this->post('edit/domain', ['items' => 'example.org', 'attr' => ['active' => '0']]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => ['example.org'], 'active' => '0']]);
|
||||
}
|
||||
|
||||
public function testAnAttributeNamedLikeTheItemsWins(): void
|
||||
{
|
||||
$result = $this->post('edit/domain', ['items' => ['example.org'], 'attr' => ['domain' => 'example.com']]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => 'example.com']]);
|
||||
}
|
||||
|
||||
public function testDeleteDomains(): void
|
||||
{
|
||||
$result = $this->post('delete/domain', ['example.org', 'example.com']);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'domain', ['domain' => ['example.org', 'example.com']]]);
|
||||
}
|
||||
|
||||
public function testDeleteDomainTags(): void
|
||||
{
|
||||
$result = $this->post('delete/domain/tag/example.org', ['customer', 'premium']);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => ['customer', 'premium'], 'domain' => 'example.org']]);
|
||||
}
|
||||
|
||||
public function testDeleteDomainTagsWithoutTags(): void
|
||||
{
|
||||
$result = $this->post('delete/domain/tag/example.org', [], ['script' => [self::answer('mailbox', ['delete', 'tags_domain'], false)]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => [], 'domain' => 'example.org']]);
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot delete item'], $result->json());
|
||||
}
|
||||
|
||||
public function testDeleteDomainTagsWithoutDomain(): void
|
||||
{
|
||||
$result = $this->post('delete/domain/tag', ['customer']);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => ['customer'], 'domain' => null]]);
|
||||
}
|
||||
|
||||
public function testEditDomainFooter(): void
|
||||
{
|
||||
$attr = ['html' => '<p>{= from_name =}</p>', 'plain' => '{= from_name =}', 'skip_replies' => 1, 'exclude' => ['noreply@example.org', 'example.net']];
|
||||
|
||||
$result = $this->post('edit/domain/footer', ['items' => ['example.org'], 'attr' => $attr]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'domain_wide_footer', ['domains' => ['example.org']] + $attr]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Domains;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** add/mta-sts, edit/mta-sts (there is no read route) */
|
||||
class MtaStsTest extends ApiTestCase
|
||||
{
|
||||
public function testAddPolicy(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'version' => 'stsv1', 'mode' => 'enforce', 'mx' => 'mail.example.org,mx2.example.org', 'max_age' => 86400, 'active' => 1];
|
||||
|
||||
$result = $this->post('add/mta-sts', $attr);
|
||||
|
||||
$this->assertSame([['add', 'mta_sts', $attr]], $result->callsTo('mailbox'));
|
||||
// the backend reports no success message of its own
|
||||
$this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $result->json());
|
||||
}
|
||||
|
||||
public function testEditPolicies(): void
|
||||
{
|
||||
$result = $this->post('edit/mta-sts', ['items' => ['example.org'], 'attr' => ['version' => 'stsv1', 'mode' => 'testing']]);
|
||||
|
||||
$this->assertSame([['edit', 'mta_sts', ['domains' => ['example.org'], 'version' => 'stsv1', 'mode' => 'testing']]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testThereIsNoReadRoute(): void
|
||||
{
|
||||
$this->assertApiError($this->get('get/mta-sts/example.org'), 404, 'route not found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Fail2Ban settings, bans and the banlist URL */
|
||||
class Fail2BanTest extends ApiTestCase
|
||||
{
|
||||
private const BANLIST_ID = 'a2c11baf-5770-47de-9c5e-575bd7bc9218';
|
||||
|
||||
public function testGetFail2BanWithAnyObject(): void
|
||||
{
|
||||
$settings = ['ban_time' => 1800, 'ban_time_increment' => true, 'banlist_id' => self::BANLIST_ID, 'whitelist' => '192.0.2.0/24'];
|
||||
|
||||
foreach (['get/fail2ban', 'get/fail2ban/all'] as $route) {
|
||||
$result = $this->get($route, ['script' => [self::answer('fail2ban', ['get'], $settings)]]);
|
||||
$this->assertSame([['get']], $result->callsTo('fail2ban'), $route);
|
||||
$this->assertSame($settings, $result->json(), $route);
|
||||
}
|
||||
|
||||
$this->assertSame('{}', $this->get('get/fail2ban', ['script' => [self::answer('fail2ban', ['get'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testEditFail2BanSettings(): void
|
||||
{
|
||||
$attr = ['ban_time' => '1800', 'max_attempts' => '10', 'whitelist' => "192.0.2.0/24\nmail.example.org"];
|
||||
|
||||
$result = $this->post('edit/fail2ban', ['items' => [], 'attr' => $attr]);
|
||||
|
||||
$this->assertSame([['edit', ['network' => []] + $attr]], $result->callsTo('fail2ban'));
|
||||
}
|
||||
|
||||
public function testItemsAreTheNetworks(): void
|
||||
{
|
||||
$list = $this->post('edit/fail2ban', ['items' => ['203.0.113.7/32', '2001:db8::/64'], 'attr' => ['action' => 'unban']]);
|
||||
$this->assertCalled($list, 'fail2ban', ['edit', ['network' => ['203.0.113.7/32', '2001:db8::/64'], 'action' => 'unban']]);
|
||||
|
||||
$single = $this->post('edit/fail2ban', ['items' => '198.51.100.66/32', 'attr' => ['action' => 'blacklist']]);
|
||||
$this->assertCalled($single, 'fail2ban', ['edit', ['network' => ['198.51.100.66/32'], 'action' => 'blacklist']]);
|
||||
}
|
||||
|
||||
public function testFurtherSegmentsAreIgnored(): void
|
||||
{
|
||||
$result = $this->post('edit/fail2ban/self', ['items' => ['self'], 'attr' => ['action' => 'unban-all']]);
|
||||
|
||||
$this->assertSame([['edit', ['network' => ['self'], 'action' => 'unban-all']]], $result->callsTo('fail2ban'));
|
||||
}
|
||||
|
||||
public function testRefreshBanlist(): void
|
||||
{
|
||||
$messages = [self::message('success', 'f2b_banlist_refreshed')];
|
||||
|
||||
$result = $this->post('edit/fail2ban/banlist', ['items' => [self::BANLIST_ID]], ['script' => [self::answer('fail2ban', ['banlist', 'refresh'], true, $messages)]]);
|
||||
$this->assertSame([['banlist', 'refresh', [self::BANLIST_ID]]], $result->callsTo('fail2ban'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
|
||||
$single = $this->post('edit/fail2ban/banlist', ['items' => self::BANLIST_ID, 'attr' => []]);
|
||||
$this->assertSame([['banlist', 'refresh', [self::BANLIST_ID]]], $single->callsTo('fail2ban'));
|
||||
}
|
||||
|
||||
public function testRefreshBanlistWithAWrongId(): void
|
||||
{
|
||||
$result = $this->post('edit/fail2ban/banlist', ['items' => ['wrong']], ['script' => [self::answer('fail2ban', ['banlist'], false)]]);
|
||||
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Global Sieve prefilter and postfilter */
|
||||
class GlobalFilterTest extends ApiTestCase
|
||||
{
|
||||
private const FILTERS = ['prefilter' => "# before\n", 'postfilter' => "require \"fileinto\";\n"];
|
||||
|
||||
public function testAddGlobalFilter(): void
|
||||
{
|
||||
$attr = ['filter_type' => 'postfilter', 'script_data' => "require \"fileinto\";\nfileinto \"Junk\";"];
|
||||
$messages = [
|
||||
self::message('success', 'dovecot_restart_success'),
|
||||
self::message('success', 'global_filter_written'),
|
||||
];
|
||||
|
||||
$result = $this->post('add/global-filter', $attr, ['script' => [self::answer('mailbox', ['add', 'global_filter'], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['add', 'global_filter', $attr]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testGetAllGlobalFilters(): void
|
||||
{
|
||||
$result = $this->get('get/global_filters/all', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)]]);
|
||||
|
||||
$this->assertSame([['get', 'global_filter_details']], $result->callsTo('mailbox'));
|
||||
$this->assertSame(self::FILTERS, $result->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/global_filters/all', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testOneGlobalFilterIsAJsonString(): void
|
||||
{
|
||||
$script = [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)];
|
||||
|
||||
$this->assertSame("# before\n", $this->get('get/global_filters/prefilter', ['script' => $script])->json());
|
||||
$this->assertSame("require \"fileinto\";\n", $this->get('get/global_filters/postfilter', ['script' => $script])->json());
|
||||
|
||||
$empty = [self::answer('mailbox', ['get', 'global_filter_details'], ['prefilter' => '', 'postfilter' => ''])];
|
||||
$this->assertSame('{}', $this->get('get/global_filters/prefilter', ['script' => $empty])->body);
|
||||
$this->assertSame('{}', $this->get('get/global_filters/postfilter', ['script' => $empty])->body);
|
||||
}
|
||||
|
||||
public function testOtherObjectsAnswerNothing(): void
|
||||
{
|
||||
$result = $this->get('get/global_filters/before', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)]]);
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Quarantine items and settings */
|
||||
class QuarantineTest extends ApiTestCase
|
||||
{
|
||||
public function testGetQuarantine(): void
|
||||
{
|
||||
$items = [['id' => 33, 'qid' => '8224615004C1', 'subject' => 'You have won', 'virus_flag' => 0, 'score' => 15.48, 'rcpt' => 'john.doe@example.org']];
|
||||
|
||||
$result = $this->get('get/quarantine/all', ['script' => [self::answer('quarantine', ['get'], $items)]]);
|
||||
$this->assertSame([['get']], $result->callsTo('quarantine'));
|
||||
$this->assertSame($items, $result->json());
|
||||
|
||||
$this->assertSame('[]', $this->get('get/quarantine/all')->body);
|
||||
}
|
||||
|
||||
public function testGetQuarantineItem(): void
|
||||
{
|
||||
$item = ['id' => 33, 'qid' => '8224615004C1', 'msg' => "Subject: You have won\r\n\r\nClick here\r\n", 'qhash' => str_repeat('a', 64)];
|
||||
|
||||
$result = $this->get('get/quarantine/33', ['script' => [self::answer('quarantine', ['details', '33'], $item)]]);
|
||||
$this->assertSame([['details', '33']], $result->callsTo('quarantine'));
|
||||
$this->assertSame($item, $result->json());
|
||||
|
||||
$this->assertSame('[]', $this->get('get/quarantine/34', ['script' => [self::answer('quarantine', ['details'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testEditQuarantineItems(): void
|
||||
{
|
||||
$release = $this->post('edit/qitem', ['items' => ['33', '34'], 'attr' => ['action' => 'release']]);
|
||||
$this->assertCalled($release, 'quarantine', ['edit', ['id' => ['33', '34'], 'action' => 'release']]);
|
||||
|
||||
$spam = $this->post('edit/qitem', ['items' => 35, 'attr' => ['action' => 'learnspam']]);
|
||||
$this->assertCalled($spam, 'quarantine', ['edit', ['id' => [35], 'action' => 'learnspam']]);
|
||||
}
|
||||
|
||||
public function testDeleteQuarantineItems(): void
|
||||
{
|
||||
$this->assertCalled($this->post('delete/qitem', ['33', '34']), 'quarantine', ['delete', ['id' => ['33', '34']]]);
|
||||
$this->assertCalled($this->post('delete/qitem', '"33"'), 'quarantine', ['delete', ['id' => ['33']]]);
|
||||
}
|
||||
|
||||
public function testEditQuarantineSettingsIgnoresItems(): void
|
||||
{
|
||||
$attr = ['action' => 'settings', 'retention_size' => '20', 'max_size' => '15', 'exclude_domains' => ['example.com']];
|
||||
$messages = [self::message('success', 'saved_settings')];
|
||||
|
||||
$result = $this->post('edit/quarantine', ['items' => ['self'], 'attr' => $attr], ['script' => [self::answer('quarantine', ['edit'], true, $messages)]]);
|
||||
|
||||
$this->assertSame([['edit', $attr]], $result->callsTo('quarantine'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Postfix mail queue */
|
||||
class QueueTest extends ApiTestCase
|
||||
{
|
||||
public function testGetMailQueuePassesTheJsonOfTheBackendThrough(): void
|
||||
{
|
||||
$queue = '[{"queue_name":"deferred","queue_id":"B98C6260CA1","sender":"sender@example.org","recipients":["rcpt@example.com"]}]';
|
||||
|
||||
$result = $this->get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], $queue)]]);
|
||||
|
||||
$this->assertSame([['get']], $result->callsTo('mailq'));
|
||||
$this->assertSame($queue, $result->body);
|
||||
}
|
||||
|
||||
public function testEmptyMailQueue(): void
|
||||
{
|
||||
$this->assertSame('[]', $this->get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], '[]')]])->body);
|
||||
$this->assertSame('[]', $this->get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testPostcatAnswersThePlainText(): void
|
||||
{
|
||||
$text = "*** ENVELOPE RECORDS deferred/B/B98C6260CA1 ***\nsender: sender@example.org\n*** MESSAGE CONTENTS deferred/B/B98C6260CA1 ***\nSubject: Test\n";
|
||||
|
||||
$result = $this->get('get/postcat/B98C6260CA1', ['script' => [self::answer('mailq', ['cat'], $text)]]);
|
||||
|
||||
$this->assertSame([['cat', ['qid' => 'B98C6260CA1']]], $result->callsTo('mailq'));
|
||||
$this->assertSame($text, $result->body);
|
||||
$this->assertNull($result->json());
|
||||
}
|
||||
|
||||
public function testPostcatWithoutAccessAnswersNothing(): void
|
||||
{
|
||||
$result = $this->get('get/postcat/B98C6260CA1', ['script' => [self::answer('mailq', ['cat'], false)]]);
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
}
|
||||
|
||||
public function testEditMailQueue(): void
|
||||
{
|
||||
$hold = $this->post('edit/mailq', ['items' => ['B98C6260CA1', '4C1E8260D02'], 'attr' => ['action' => 'hold']]);
|
||||
$this->assertCalled($hold, 'mailq', ['edit', ['qid' => ['B98C6260CA1', '4C1E8260D02'], 'action' => 'hold']]);
|
||||
|
||||
$deliver = $this->post('edit/mailq', ['items' => 'B98C6260CA1', 'attr' => ['action' => 'deliver']]);
|
||||
$this->assertCalled($deliver, 'mailq', ['edit', ['qid' => ['B98C6260CA1'], 'action' => 'deliver']]);
|
||||
|
||||
$flush = $this->post('edit/mailq', ['items' => [], 'attr' => ['action' => 'flush']]);
|
||||
$this->assertCalled($flush, 'mailq', ['edit', ['qid' => [], 'action' => 'flush']]);
|
||||
}
|
||||
|
||||
public function testAnActionOutsideOfAttrIsNotSeen(): void
|
||||
{
|
||||
$result = $this->post('edit/mailq', ['action' => 'flush']);
|
||||
|
||||
$this->assertCalled($result, 'mailq', ['edit', ['qid' => []]]);
|
||||
$this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $result->json());
|
||||
}
|
||||
|
||||
public function testDeleteQueuedMessages(): void
|
||||
{
|
||||
$this->assertCalled($this->post('delete/mailq', ['B98C6260CA1', '4C1E8260D02']), 'mailq', ['delete', ['qid' => ['B98C6260CA1', '4C1E8260D02']]]);
|
||||
$this->assertCalled($this->post('delete/mailq', '"B98C6260CA1"'), 'mailq', ['delete', ['qid' => ['B98C6260CA1']]]);
|
||||
}
|
||||
|
||||
public function testDeleteTakesNoAction(): void
|
||||
{
|
||||
$result = $this->post('delete/mailq', ['action' => 'super_delete']);
|
||||
|
||||
$this->assertSame([['delete', ['qid' => ['action' => 'super_delete']]]], $result->callsTo('mailq'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Quota warning mails and their per-domain BCC */
|
||||
class QuotaNotificationTest extends ApiTestCase
|
||||
{
|
||||
public function testEditQuotaNotificationIgnoresItems(): void
|
||||
{
|
||||
$attr = ['sender' => 'quota-warning@example.org', 'subject' => 'Quota warning', 'html_tmpl' => ''];
|
||||
|
||||
$result = $this->post('edit/quota_notification', ['items' => ['quota_notification'], 'attr' => $attr]);
|
||||
|
||||
$this->assertSame([['edit', $attr]], $result->callsTo('quota_notification'));
|
||||
}
|
||||
|
||||
public function testEditQuotaNotificationBccIgnoresItems(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'bcc_rcpt' => "postmaster@example.org\nhelpdesk@example.org", 'active' => '1'];
|
||||
$messages = [self::message('success', 'saved_settings')];
|
||||
|
||||
$result = $this->post('edit/quota_notification_bcc', ['items' => ['quota_bcc'], 'attr' => $attr], ['script' => [self::answer('quota_notification_bcc', ['edit'], null, $messages)]]);
|
||||
|
||||
$this->assertSame([['edit', $attr]], $result->callsTo('quota_notification_bcc'));
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Filtering;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Rspamd settings rules, global regex maps, action statistics and presets */
|
||||
class RspamdTest extends ApiTestCase
|
||||
{
|
||||
private const RULE = ['desc' => 'Disable Rspamd for example.org', 'content' => 'priority = 10;', 'active' => '1'];
|
||||
|
||||
public function testAddRsetting(): void
|
||||
{
|
||||
$messages = [self::message('success', 'settings_map_added', ['rsettings', 'add', self::RULE])];
|
||||
|
||||
$result = $this->post('add/rsetting', self::RULE, ['script' => [self::answer('rsettings', ['add'], null, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, 'rsettings', ['add', self::RULE]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testGetAllRsettingsReadsTheDetailsOfEveryRule(): void
|
||||
{
|
||||
$first = ['id' => 1, 'desc' => 'first', 'content' => 'priority = 10;', 'active' => 1];
|
||||
$third = ['id' => 3, 'desc' => 'third', 'content' => 'priority = 5;', 'active' => 0];
|
||||
|
||||
$result = $this->get('get/rsetting/all', ['script' => [
|
||||
self::answer('rsettings', ['get'], [['id' => 1, 'desc' => 'first', 'active' => 1], ['id' => 2, 'desc' => 'gone', 'active' => 1], ['id' => 3, 'desc' => 'third', 'active' => 0]]),
|
||||
self::answer('rsettings', ['details', 1], $first),
|
||||
self::answer('rsettings', ['details', 2], false),
|
||||
self::answer('rsettings', ['details', 3], $third),
|
||||
]]);
|
||||
|
||||
$this->assertSame([['get'], ['details', 1], ['details', 2], ['details', 3]], $result->callsTo('rsettings'));
|
||||
$this->assertSame([$first, $third], $result->json());
|
||||
}
|
||||
|
||||
public function testGetAllRsettingsWithoutRules(): void
|
||||
{
|
||||
$result = $this->get('get/rsetting/all', ['script' => [self::answer('rsettings', ['get'], false)]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
$this->assertSame([['get']], $result->callsTo('rsettings'));
|
||||
}
|
||||
|
||||
public function testGetOneRsetting(): void
|
||||
{
|
||||
$rule = ['id' => 7, 'desc' => 'seven', 'content' => 'priority = 10;', 'active' => 1];
|
||||
|
||||
$found = $this->get('get/rsetting/7', ['script' => [self::answer('rsettings', ['details', '7'], $rule)]]);
|
||||
$this->assertSame([['details', '7']], $found->callsTo('rsettings'));
|
||||
$this->assertSame($rule, $found->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/rsetting/8', ['script' => [self::answer('rsettings', ['details'], false)]])->body);
|
||||
}
|
||||
|
||||
public function testEditRsetting(): void
|
||||
{
|
||||
$result = $this->post('edit/rsetting', ['items' => [1, 2], 'attr' => ['desc' => 'renamed', 'active' => '0']]);
|
||||
$this->assertCalled($result, 'rsettings', ['edit', ['id' => [1, 2], 'desc' => 'renamed', 'active' => '0']]);
|
||||
|
||||
$single = $this->post('edit/rsetting', ['items' => '3', 'attr' => ['content' => 'priority = 1;']]);
|
||||
$this->assertCalled($single, 'rsettings', ['edit', ['id' => ['3'], 'content' => 'priority = 1;']]);
|
||||
}
|
||||
|
||||
public function testDeleteRsetting(): void
|
||||
{
|
||||
$this->assertCalled($this->post('delete/rsetting', [1, 2]), 'rsettings', ['delete', ['id' => [1, 2]]]);
|
||||
$this->assertCalled($this->post('delete/rsetting', 3), 'rsettings', ['delete', ['id' => [3]]]);
|
||||
}
|
||||
|
||||
public function testEditRspamdMap(): void
|
||||
{
|
||||
$data = "/^spammer@example\\.com$/i\n/@spam\\.example\\.net$/i";
|
||||
|
||||
$result = $this->post('edit/rspamd-map', ['items' => ['global_smtp_from_blacklist.map', 'global_rcpt_blacklist.map'], 'attr' => ['rspamd_map_data' => $data]]);
|
||||
|
||||
$this->assertCalled($result, 'rspamd_maps', ['edit', ['map' => ['global_smtp_from_blacklist.map', 'global_rcpt_blacklist.map'], 'rspamd_map_data' => $data]]);
|
||||
}
|
||||
|
||||
public function testRspamdActions(): void
|
||||
{
|
||||
$actions = [['reject', 38], ['soft reject', 1], ['add header', 6], ['no action', 255]];
|
||||
|
||||
$result = $this->get('get/rspamd/actions', ['script' => [self::answer('rspamd_actions', [], $actions)]]);
|
||||
$this->assertSame([[]], $result->callsTo('rspamd_actions'));
|
||||
$this->assertSame($actions, $result->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/rspamd/actions', ['script' => [self::answer('rspamd_actions', [], false)]])->body);
|
||||
}
|
||||
|
||||
public function testOtherRspamdObjectsAnswerNothing(): void
|
||||
{
|
||||
$result = $this->get('get/rspamd/stat');
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
$this->assertNotCalled($result, 'rspamd_actions');
|
||||
}
|
||||
|
||||
public function testPresets(): void
|
||||
{
|
||||
$preset = [['name' => 'preset_1', 'headline' => 'Disable all but DKIM', 'content' => 'priority = 10;']];
|
||||
|
||||
$rspamd = $this->get('get/presets/rspamd', ['script' => [self::answer('presets', ['get', 'rspamd'], $preset)]]);
|
||||
$this->assertSame([['get', 'rspamd']], $rspamd->callsTo('presets'));
|
||||
$this->assertSame($preset, $rspamd->json());
|
||||
|
||||
$sieve = $this->get('get/presets/sieve', ['session' => self::DOMAIN_ADMIN]);
|
||||
$this->assertSame([['get', 'sieve']], $sieve->callsTo('presets'));
|
||||
$this->assertSame('{}', $sieve->body);
|
||||
|
||||
$other = $this->get('get/presets/other');
|
||||
$this->assertSame([200, ''], [$other->status, $other->body]);
|
||||
$this->assertNotCalled($other, 'presets');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
// Runs data/web/json_api.php for one request of ApiTestCase in a process of its own. The request comes from the JSON
|
||||
// file in argv[1]; every legacy function and class json_api.php calls is a recorder; the status code, the output and
|
||||
// the recorded calls go to the result file named in the request.
|
||||
|
||||
namespace ApiHarness {
|
||||
|
||||
/** The recorded calls and the scripted answers of one request */
|
||||
final class Calls
|
||||
{
|
||||
/** @var list<array{0: string, 1: array}> */
|
||||
public static array $calls = [];
|
||||
/** @var list<array{call: string, args?: array, return?: mixed, messages?: list<array>, once?: bool}> */
|
||||
public static array $script = [];
|
||||
/** Methods that return a recorder unless scripted, so that chains like Module::fromGlobals()->api->x() work */
|
||||
private const CHAINED = ['fromGlobals', 'create', 'prepare', 'query'];
|
||||
|
||||
public static function record(string $name, array $args, ?string $method = null): mixed
|
||||
{
|
||||
$args = self::plain($args);
|
||||
self::$calls[] = [$name, $args];
|
||||
foreach (self::$script as $i => $entry) {
|
||||
if ($entry['call'] !== $name || !self::startsWith($args, $entry['args'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entry['messages'] ?? [] as $message) {
|
||||
$_SESSION['return'][] = $message;
|
||||
}
|
||||
if (!empty($entry['once'])) {
|
||||
unset(self::$script[$i]);
|
||||
}
|
||||
return $entry['return'] ?? null;
|
||||
}
|
||||
return ($method !== null && in_array($method, self::CHAINED, true))
|
||||
? new Recorder(preg_replace('/::(fromGlobals|create)$/', '', $name)) : null;
|
||||
}
|
||||
|
||||
/** Arguments as JSON data: recorders and other objects become "@<name>" */
|
||||
private static function plain(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof Recorder) {
|
||||
return '@' . $value->label();
|
||||
}
|
||||
if (is_object($value)) {
|
||||
return '@' . get_class($value);
|
||||
}
|
||||
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||||
}
|
||||
|
||||
private static function startsWith(array $args, array $prefix): bool
|
||||
{
|
||||
foreach (array_values($prefix) as $i => $expected) {
|
||||
if (!array_key_exists($i, $args) || $args[$i] !== $expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand-in for objects and classes: records method calls, reads give further recorders */
|
||||
class Recorder implements \Countable, \IteratorAggregate, \ArrayAccess, \JsonSerializable
|
||||
{
|
||||
private string $recorderLabel;
|
||||
|
||||
public function __construct(mixed ...$args)
|
||||
{
|
||||
$this->recorderLabel = (isset($args[0]) && is_string($args[0]) && static::class === self::class) ? $args[0] : static::class;
|
||||
if (static::class !== self::class) {
|
||||
Calls::record('new ' . static::class, $args);
|
||||
}
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->recorderLabel;
|
||||
}
|
||||
|
||||
public function __get(string $name): self
|
||||
{
|
||||
return new self($this->recorderLabel . '->' . $name);
|
||||
}
|
||||
|
||||
public function __call(string $name, array $args): mixed
|
||||
{
|
||||
return Calls::record($this->recorderLabel . '->' . $name, $args, $name);
|
||||
}
|
||||
|
||||
public static function __callStatic(string $name, array $args): mixed
|
||||
{
|
||||
return Calls::record(static::class . '::' . $name, $args, $name);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getIterator(): \Iterator
|
||||
{
|
||||
return new \EmptyIterator();
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return ['@' => $this->recorderLabel];
|
||||
}
|
||||
}
|
||||
|
||||
/** php://input with the request body (the CLI has none); every other php:// stream is the real one */
|
||||
final class InputStream
|
||||
{
|
||||
public static string $body = '';
|
||||
/** @var resource|null */
|
||||
public $context;
|
||||
private int $position = 0;
|
||||
/** @var resource|null */
|
||||
private $inner = null;
|
||||
|
||||
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
|
||||
{
|
||||
if (strtolower($path) === 'php://input') {
|
||||
return true;
|
||||
}
|
||||
stream_wrapper_restore('php');
|
||||
$this->inner = fopen($path, $mode) ?: null;
|
||||
stream_wrapper_unregister('php');
|
||||
stream_wrapper_register('php', self::class);
|
||||
return $this->inner !== null;
|
||||
}
|
||||
|
||||
public function stream_read(int $count): string|false
|
||||
{
|
||||
if ($this->inner !== null) {
|
||||
return fread($this->inner, $count);
|
||||
}
|
||||
$chunk = substr(self::$body, $this->position, $count);
|
||||
$this->position += strlen($chunk);
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
public function stream_write(string $data): int
|
||||
{
|
||||
return ($this->inner !== null) ? (int)fwrite($this->inner, $data) : 0;
|
||||
}
|
||||
|
||||
public function stream_eof(): bool
|
||||
{
|
||||
return ($this->inner !== null) ? feof($this->inner) : $this->position >= strlen(self::$body);
|
||||
}
|
||||
|
||||
public function stream_stat(): array|false
|
||||
{
|
||||
return ($this->inner !== null) ? fstat($this->inner) : ['size' => strlen(self::$body)];
|
||||
}
|
||||
|
||||
public function stream_close(): void
|
||||
{
|
||||
if ($this->inner !== null) {
|
||||
fclose($this->inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Names of the functions $file calls that are neither PHP functions nor declared in $file */
|
||||
function calledFunctions(string $file): array
|
||||
{
|
||||
$tokens = array_values(array_filter(token_get_all(file_get_contents($file)),
|
||||
fn($t) => !is_array($t) || !in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)));
|
||||
$declared = [];
|
||||
$called = [];
|
||||
foreach ($tokens as $i => $token) {
|
||||
if (!is_array($token) || $token[0] !== T_STRING) {
|
||||
continue;
|
||||
}
|
||||
$previous = $tokens[$i - 1] ?? null;
|
||||
$next = $tokens[$i + 1] ?? null;
|
||||
if (is_array($previous) && $previous[0] === T_FUNCTION) {
|
||||
$declared[strtolower($token[1])] = true;
|
||||
continue;
|
||||
}
|
||||
if ($next !== '(' || (is_array($previous) && in_array($previous[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_NEW], true))) {
|
||||
continue;
|
||||
}
|
||||
$called[strtolower($token[1])] = $token[1];
|
||||
}
|
||||
return array_values(array_filter($called, fn(string $name): bool => !isset($declared[strtolower($name)]) && !function_exists($name)));
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
use ApiHarness\Calls;
|
||||
use ApiHarness\InputStream;
|
||||
|
||||
$request = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
|
||||
$jsonApi = $request['json_api'];
|
||||
|
||||
foreach (ApiHarness\calledFunctions($jsonApi) as $name) {
|
||||
eval('function ' . $name . '(...$args) { return \ApiHarness\Calls::record(' . var_export($name, true) . ', $args); }');
|
||||
}
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$parts = explode('\\', ltrim($class, '\\'));
|
||||
$short = array_pop($parts);
|
||||
$namespace = implode('\\', $parts);
|
||||
eval(($namespace !== '' ? "namespace $namespace; " : '') . "class $short extends \\ApiHarness\\Recorder {}");
|
||||
});
|
||||
|
||||
$_SERVER['DOCUMENT_ROOT'] = __DIR__ . '/webroot';
|
||||
$_SERVER['REQUEST_METHOD'] = $request['method'];
|
||||
$_SERVER['REQUEST_URI'] = '/api/v1/' . $request['query'];
|
||||
$_SERVER['REMOTE_ADDR'] = '192.0.2.10';
|
||||
if (isset($request['content_type'])) {
|
||||
$_SERVER['HTTP_CONTENT_TYPE'] = $request['content_type'];
|
||||
}
|
||||
foreach ($request['server'] ?? [] as $key => $value) {
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
$_GET = ['query' => $request['query']] + ($request['get'] ?? []);
|
||||
$_POST = $request['post'] ?? [];
|
||||
$GLOBALS['__harness_session'] = $request['session'] ?? [];
|
||||
$GLOBALS['__harness_globals'] = $request['globals'] ?? [];
|
||||
Calls::$script = $request['script'] ?? [];
|
||||
InputStream::$body = (string)($request['body'] ?? '');
|
||||
stream_wrapper_unregister('php');
|
||||
stream_wrapper_register('php', InputStream::class);
|
||||
|
||||
register_shutdown_function(function () use ($request): void {
|
||||
$output = '';
|
||||
while (ob_get_level() > 0) {
|
||||
$output = ob_get_clean() . $output;
|
||||
}
|
||||
$error = error_get_last();
|
||||
$fatal = ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true));
|
||||
stream_wrapper_restore('php');
|
||||
file_put_contents($request['result'], json_encode([
|
||||
'status' => http_response_code() ?: 200,
|
||||
'body' => $output,
|
||||
'calls' => Calls::$calls,
|
||||
'session_return' => $_SESSION['return'] ?? null,
|
||||
'error' => $fatal ? $error['message'] . ' in ' . $error['file'] . ':' . $error['line'] : null,
|
||||
], JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
});
|
||||
|
||||
ob_start();
|
||||
require $jsonApi;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
// Stand-in for data/web/inc/lib/ssp.class.php in the API harness: the class SSP is a recorder (runner.php autoloader).
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// Stand-in for data/web/inc/prerequisites.inc.php in the API harness (runner.php): the session of the request
|
||||
// instead of the API key login, recorders for the database and Redis, the globals the test sets.
|
||||
|
||||
$pdo = new \ApiHarness\Recorder('pdo');
|
||||
$redis = new \ApiHarness\Recorder('redis');
|
||||
$_SESSION = $GLOBALS['__harness_session'] ?? [];
|
||||
foreach ($GLOBALS['__harness_globals'] ?? [] as $name => $value) {
|
||||
$GLOBALS[$name] = $value;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** App passwords of mailboxes */
|
||||
class AppPasswordRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testAddAppPassword(): void
|
||||
{
|
||||
$body = ['username' => 'john@example.org', 'app_name' => 'Thunderbird', 'app_passwd' => 'eiQu4ahz8iePh', 'app_passwd2' => 'eiQu4ahz8iePh', 'protocols' => ['imap_access']];
|
||||
|
||||
$this->assertCalled($this->post('add/app-passwd', $body), 'app_passwd', ['add', $body]);
|
||||
}
|
||||
|
||||
public function testListsOfAMailboxAndOfTheSessionUser(): void
|
||||
{
|
||||
$script = [
|
||||
self::answer('app_passwd', ['get'], [['id' => 23, 'name' => 'Thunderbird'], ['id' => 24, 'name' => 'Gone']]),
|
||||
self::answer('app_passwd', ['details', 23], ['id' => 23, 'mailbox' => 'john@example.org']),
|
||||
self::answer('app_passwd', ['details', 24], false),
|
||||
];
|
||||
|
||||
$mailbox = $this->get('get/app-passwd/all/john@example.org', ['script' => $script]);
|
||||
$this->assertSame([['get', ['username' => 'john@example.org']], ['details', 23], ['details', 24]], $mailbox->callsTo('app_passwd'));
|
||||
$this->assertSame([['id' => 23, 'mailbox' => 'john@example.org']], $mailbox->json());
|
||||
|
||||
$own = $this->get('get/app-passwd/all', ['session' => self::USER, 'script' => $script]);
|
||||
$this->assertSame(['get'], $own->callsTo('app_passwd')[0]);
|
||||
|
||||
$this->assertSame('{}', $this->get('get/app-passwd/all')->body);
|
||||
}
|
||||
|
||||
public function testSingleAppPasswordFails(): void
|
||||
{
|
||||
$result = $this->get('get/app-passwd/23', ['allow_error' => true]);
|
||||
|
||||
$this->assertStringContainsString('Cannot access offset of type string on string', (string)$result->error);
|
||||
$this->assertNotCalled($result, 'app_passwd');
|
||||
}
|
||||
|
||||
public function testEditAndDeleteAppPasswords(): void
|
||||
{
|
||||
$edit = $this->post('edit/app-passwd', ['items' => [23], 'attr' => ['username' => 'john@example.org', 'active' => '0']]);
|
||||
$this->assertCalled($edit, 'app_passwd', ['edit', ['id' => [23], 'username' => 'john@example.org', 'active' => '0']]);
|
||||
|
||||
$this->assertCalled($this->post('delete/app-passwd', [23, 24]), 'app_passwd', ['delete', ['id' => [23, 24]]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Sieve filters and the spam allow/deny lists of mailboxes */
|
||||
class FilterAndPolicyRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testAddEditDeleteFilters(): void
|
||||
{
|
||||
$body = ['username' => 'john@example.org', 'script_desc' => 'Move newsletters', 'script_data' => 'keep;', 'filter_type' => 'prefilter', 'active' => 1];
|
||||
$this->assertCalled($this->post('add/filter', $body), 'mailbox', ['add', 'filter', $body]);
|
||||
|
||||
$this->assertCalled($this->post('edit/filter', ['items' => [7], 'attr' => ['active' => 0]]), 'mailbox', ['edit', 'filter', ['id' => [7], 'active' => 0]]);
|
||||
$this->assertCalled($this->post('delete/filter', [7]), 'mailbox', ['delete', 'filter', ['id' => [7]]]);
|
||||
}
|
||||
|
||||
public function testAllFiltersOfAllMailboxes(): void
|
||||
{
|
||||
$result = $this->get('get/filters/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'jane@example.org']),
|
||||
self::answer('mailbox', ['get', 'filters', 'john@example.org'], [7, 8]),
|
||||
self::answer('mailbox', ['get', 'filter_details', 7], ['id' => 7]),
|
||||
self::answer('mailbox', ['get', 'filter_details', 8], false),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'filters', 'jane@example.org']);
|
||||
$this->assertSame([['id' => 7]], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/filters/all')->body);
|
||||
}
|
||||
|
||||
public function testFiltersOfAMailbox(): void
|
||||
{
|
||||
$result = $this->get('get/filters/john@example.org', ['script' => [
|
||||
self::answer('mailbox', ['get', 'filters'], [7]),
|
||||
self::answer('mailbox', ['get', 'filter_details', 7], ['id' => 7, 'username' => 'john@example.org']),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'filters', 'john@example.org']);
|
||||
$this->assertSame([['id' => 7, 'username' => 'john@example.org']], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/filters/jane@example.org')->body);
|
||||
}
|
||||
|
||||
public function testActiveUserSieveIsAListWithOneScript(): void
|
||||
{
|
||||
$result = $this->get('get/active-user-sieve/john@example.org', ['script' => [self::answer('mailbox', ['get', 'active_user_sieve'], "require \"fileinto\";\nkeep;")]]);
|
||||
|
||||
$this->assertSame([['get', 'active_user_sieve', 'john@example.org']], $result->callsTo('mailbox'));
|
||||
$this->assertSame(["require \"fileinto\";\nkeep;"], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/active-user-sieve/john@example.org', ['script' => [self::answer('mailbox', [], false)]])->body);
|
||||
|
||||
$none = $this->get('get/active-user-sieve');
|
||||
$this->assertNotCalled($none, 'mailbox');
|
||||
$this->assertSame('{}', $none->body);
|
||||
}
|
||||
|
||||
public function testAddAndDeleteMailboxPolicies(): void
|
||||
{
|
||||
$body = ['username' => 'john@example.org', 'object_list' => 'bl', 'object_from' => '*@spam.example.com'];
|
||||
$this->assertCalled($this->post('add/mailbox-policy', $body), 'policy', ['add', 'mailbox', $body]);
|
||||
|
||||
$this->assertCalled($this->post('delete/mailbox-policy', [13]), 'policy', ['delete', 'mailbox', ['prefid' => [13]]]);
|
||||
}
|
||||
|
||||
public function testAllowAndDenyListsArePartsOfThePolicy(): void
|
||||
{
|
||||
$policy = [
|
||||
'whitelist' => [['object' => 'john@example.org', 'value' => '*@partner.example.com', 'prefid' => 12]],
|
||||
'blacklist' => [['object' => 'example.org', 'value' => '*@spam.example.com', 'prefid' => 13]],
|
||||
];
|
||||
$script = [self::answer('policy', ['get', 'mailbox'], $policy)];
|
||||
|
||||
$allow = $this->get('get/policy_wl_mailbox/john@example.org', ['script' => $script]);
|
||||
$this->assertSame([['get', 'mailbox', 'john@example.org']], $allow->callsTo('policy'));
|
||||
$this->assertSame($policy['whitelist'], $allow->json());
|
||||
|
||||
$this->assertSame($policy['blacklist'], $this->get('get/policy_bl_mailbox/john@example.org', ['script' => $script])->json());
|
||||
$this->assertSame('{}', $this->get('get/policy_bl_mailbox/nobody@example.org')->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Mailboxes, their templates, tags, custom attributes, renaming, ACLs, logins and resets */
|
||||
class MailboxRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testAddMailbox(): void
|
||||
{
|
||||
$body = ['local_part' => 'john.doe', 'domain' => 'example.org', 'password' => 'Moo2Shoh5aiXu', 'password2' => 'Moo2Shoh5aiXu', 'tags' => ['sales']];
|
||||
|
||||
$this->assertCalled($this->post('add/mailbox', $body), 'mailbox', ['add', 'mailbox', $body]);
|
||||
// any other object is a mailbox as well
|
||||
$this->assertCalled($this->post('add/mailbox/something', $body), 'mailbox', ['add', 'mailbox', $body]);
|
||||
}
|
||||
|
||||
public function testAddMailboxTemplate(): void
|
||||
{
|
||||
$body = ['template' => 'Sales', 'quota' => 2048, 'acl' => ['spam_alias']];
|
||||
|
||||
$this->assertCalled($this->post('add/mailbox/template', $body), 'mailbox', ['add', 'mailbox_templates', $body]);
|
||||
}
|
||||
|
||||
public function testGetAllMailboxesWalksAllDomains(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'gone@example.org']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.com'], ['jane@example.com']),
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']),
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'gone@example.org'], false),
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'jane@example.com'], ['username' => 'jane@example.com']),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.org', null]);
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org', 'all']);
|
||||
$this->assertSame([['username' => 'john@example.org'], ['username' => 'jane@example.com']], $result->json());
|
||||
}
|
||||
|
||||
public function testReducedDetailsOfGivenDomainsWithTags(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox/reduced/example.org,example.com', [
|
||||
'query' => ['tags' => 'sales,support'],
|
||||
'script' => [
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org']),
|
||||
self::answer('mailbox', ['get', 'mailbox_details'], ['username' => 'john@example.org']),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertNotContains(['get', 'domains'], $result->callsTo('mailbox'));
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.org', ['sales', 'support']]);
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.com', ['sales', 'support']]);
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org', 'reduced']);
|
||||
$this->assertSame([['username' => 'john@example.org']], $result->json());
|
||||
}
|
||||
|
||||
public function testMailboxListsWithoutResults(): void
|
||||
{
|
||||
$this->assertSame('{}', $this->get('get/mailbox/all')->body);
|
||||
$this->assertSame('{}', $this->get('get/mailbox/all/example.org')->body);
|
||||
}
|
||||
|
||||
public function testGetOneMailbox(): void
|
||||
{
|
||||
$found = $this->get('get/mailbox/john@example.org', ['script' => [
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org', 'quota' => 0]),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($found, 'mailbox', ['get', 'mailbox_details', 'john@example.org']);
|
||||
$this->assertSame(['username' => 'john@example.org', 'quota' => 0], $found->json());
|
||||
$this->assertSame('{}', $this->get('get/mailbox/nobody@example.org')->body);
|
||||
}
|
||||
|
||||
public function testGetMailboxWithTagsSearchesByAddressPart(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox/example.org', [
|
||||
'query' => ['tags' => 'sales'],
|
||||
'script' => [
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org', ['sales']], ['john@example.org']),
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org']);
|
||||
$this->assertSame([['username' => 'john@example.org']], $result->json());
|
||||
$this->assertSame('[]', $this->get('get/mailbox/example.org', ['query' => ['tags' => 'sales']])->body);
|
||||
}
|
||||
|
||||
public function testGetMailboxTemplates(): void
|
||||
{
|
||||
$templates = [['id' => 2, 'template' => 'Default', 'type' => 'mailbox', 'attributes' => ['quota' => 0]]];
|
||||
$all = $this->get('get/mailbox/template/all', ['script' => [self::answer('mailbox', ['get', 'mailbox_templates'], $templates)]]);
|
||||
|
||||
$this->assertSame([['get', 'mailbox_templates']], $all->callsTo('mailbox'));
|
||||
$this->assertSame($templates, $all->json());
|
||||
$this->assertCalled($this->get('get/mailbox/template/2'), 'mailbox', ['get', 'mailbox_templates', '2']);
|
||||
}
|
||||
|
||||
public function testEditMailbox(): void
|
||||
{
|
||||
$result = $this->post('edit/mailbox', ['items' => 'john@example.org', 'attr' => ['quota' => 5120, 'sender_acl' => ['@example.com']]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'mailbox', ['username' => ['john@example.org'], 'quota' => 5120, 'sender_acl' => ['@example.com']]]);
|
||||
}
|
||||
|
||||
public function testEditMailboxTemplatesAndCustomAttributes(): void
|
||||
{
|
||||
$template = $this->post('edit/mailbox/template', ['items' => [3], 'attr' => ['template' => 'Sales', 'quarantine_category' => 'reject']]);
|
||||
$this->assertCalled($template, 'mailbox', ['edit', 'mailbox_templates', ['ids' => [3], 'template' => 'Sales', 'quarantine_category' => 'reject']]);
|
||||
|
||||
$custom = $this->post('edit/mailbox/custom-attribute', ['items' => ['john@example.org'], 'attr' => ['attribute' => ['department'], 'value' => ['Sales']]]);
|
||||
$this->assertCalled($custom, 'mailbox', ['edit', 'mailbox_custom_attribute', ['mailboxes' => ['john@example.org'], 'attribute' => ['department'], 'value' => ['Sales']]]);
|
||||
}
|
||||
|
||||
public function testRenameMailboxTakesTheNamesFromAttr(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'old_local_part' => 'john.doe', 'new_local_part' => 'john', 'create_alias' => 1];
|
||||
|
||||
$result = $this->post('edit/rename-mbox', ['items' => [], 'attr' => $attr]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'mailbox_rename', ['mailbox' => []] + $attr]);
|
||||
}
|
||||
|
||||
public function testEditUserAcl(): void
|
||||
{
|
||||
$result = $this->post('edit/user-acl', ['items' => ['john@example.org'], 'attr' => ['user_acl' => ['spam_alias', 'app_passwds']]]);
|
||||
|
||||
$this->assertCalled($result, 'acl', ['edit', 'user', ['username' => ['john@example.org'], 'user_acl' => ['spam_alias', 'app_passwds']]]);
|
||||
}
|
||||
|
||||
public function testDeleteMailboxesTagsAndTemplates(): void
|
||||
{
|
||||
$this->assertCalled($this->post('delete/mailbox', ['john@example.org']), 'mailbox', ['delete', 'mailbox', ['username' => ['john@example.org']]]);
|
||||
|
||||
$tags = $this->post('delete/mailbox/tag/john@example.org', ['sales', 'trainee']);
|
||||
$this->assertCalled($tags, 'mailbox', ['delete', 'tags_mailbox', ['tags' => ['sales', 'trainee'], 'username' => 'john@example.org']]);
|
||||
|
||||
$this->assertCalled($this->post('delete/mailbox/template', [3]), 'mailbox', ['delete', 'mailbox_templates', ['ids' => [3]]]);
|
||||
}
|
||||
|
||||
public function testResetEasCacheAndSogoProfile(): void
|
||||
{
|
||||
$this->assertCalled($this->post('delete/eas_cache', ['john@example.org']), 'mailbox', ['delete', 'eas_cache', ['username' => ['john@example.org']]]);
|
||||
$this->assertCalled($this->post('delete/sogo_profile', ['john@example.org']), 'mailbox', ['delete', 'sogo_profile', ['username' => ['john@example.org']]]);
|
||||
}
|
||||
|
||||
public function testLastLogin(): void
|
||||
{
|
||||
$logins = ['ui' => false, 'sasl' => [['real_rip' => '198.51.100.23', 'service' => 'imap']]];
|
||||
$default = $this->get('get/last-login/john@example.org', ['script' => [self::answer('last_login', [], $logins)]]);
|
||||
$this->assertSame([['get', 'john@example.org']], $default->callsTo('last_login'));
|
||||
$this->assertSame($logins, $default->json());
|
||||
|
||||
$this->assertSame([['get', 'john@example.org', 30]], $this->get('get/last-login/john@example.org/30')->callsTo('last_login'));
|
||||
$this->assertSame([['get', 'john@example.org']], $this->get('get/last-login/john@example.org/0')->callsTo('last_login'));
|
||||
|
||||
$none = $this->get('get/last-login');
|
||||
$this->assertSame('', $none->body);
|
||||
$this->assertNotCalled($none, 'last_login');
|
||||
}
|
||||
|
||||
public function testResetLastLoginIsAReadRoute(): void
|
||||
{
|
||||
$result = $this->get('get/reset-last-login/john@example.org', ['session' => self::API_RO, 'script' => [self::answer('last_login', ['reset'], true)]]);
|
||||
|
||||
$this->assertSame([['reset', 'john@example.org']], $result->callsTo('last_login'));
|
||||
$this->assertSame('true', $result->body);
|
||||
}
|
||||
|
||||
public function testBccDestinationOptions(): void
|
||||
{
|
||||
$result = $this->get('get/bcc-destination-options', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], ['example.net']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org']),
|
||||
self::answer('user_get_alias_details', ['john@example.org'], [
|
||||
'direct_aliases' => ['sales@example.org' => ['public_comment' => ''], 'john@other.example' => ['public_comment' => '']],
|
||||
'shared_aliases' => ['team@example.org' => ['public_comment' => '']],
|
||||
]),
|
||||
self::answer('hasDomainAccess', ['API', 'admin', 'example.org'], true),
|
||||
]]);
|
||||
|
||||
$this->assertSame([
|
||||
'domains' => ['example.org'],
|
||||
'mailboxes' => ['john@example.org' => ['john@example.org', 'sales@example.org', 'team@example.org']],
|
||||
'alias_domains' => ['example.net'],
|
||||
], $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiResult;
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** search/mailbox: the DataTables server-side search of the mailbox table */
|
||||
class MailboxSearchTest extends ApiTestCase
|
||||
{
|
||||
private const REQUEST = [
|
||||
'draw' => 1,
|
||||
'start' => 0,
|
||||
'length' => 25,
|
||||
'search' => ['value' => 'john'],
|
||||
'order' => [['column' => 2, 'dir' => 'asc']],
|
||||
'columns' => [['data' => 'username', 'searchable' => true, 'orderable' => true]],
|
||||
];
|
||||
|
||||
private const KIND_FILTER = "(`m`.`kind` = '' OR `m`.`kind` = NULL)";
|
||||
|
||||
public function testAdminsSearchAllMailboxes(): void
|
||||
{
|
||||
$call = $this->searchCall($this->post('search/mailbox', self::REQUEST));
|
||||
|
||||
$this->assertSame([self::REQUEST, '@pdo', ['mailbox', 'm'], 'username'], array_slice($call, 0, 4));
|
||||
$this->assertSame([null, self::KIND_FILTER], array_slice($call, 5));
|
||||
$this->assertSame([2 => 'username', 3 => 'quota', 4 => 'last_mail_login', 5 => 'last_pw_change', 6 => 'in_use', 7 => 'name',
|
||||
20 => 'messages', 23 => 'tags', 24 => 'active'], array_column($call[4], 'db', 'dt'));
|
||||
}
|
||||
|
||||
public function testReadOnlyKeysMaySearch(): void
|
||||
{
|
||||
$this->searchCall($this->post('search/mailbox', self::REQUEST, ['session' => self::API_RO]));
|
||||
}
|
||||
|
||||
public function testDomainAdminsSearchTheirDomains(): void
|
||||
{
|
||||
$call = $this->searchCall($this->post('search/mailbox', self::REQUEST, ['session' => self::DOMAIN_ADMIN]));
|
||||
|
||||
$this->assertSame('INNER JOIN domain_admins as da ON da.domain = m.domain', $call[5]);
|
||||
$this->assertSame([
|
||||
'condition' => self::KIND_FILTER . ' AND `da`.`active` = 1 AND `da`.`username` = :username',
|
||||
'bindings' => ['username' => 'domainadmin'],
|
||||
], $call[6]);
|
||||
}
|
||||
|
||||
public function testOthersAreRefused(): void
|
||||
{
|
||||
foreach ([self::USER, self::NO_SESSION] as $session) {
|
||||
$result = $this->post('search/mailbox', self::REQUEST, ['session' => $session]);
|
||||
$this->assertApiError($result, 403, 'Insufficient permissions');
|
||||
$this->assertNotCalled($result, 'SSP::complex');
|
||||
}
|
||||
}
|
||||
|
||||
public function testRowsBecomeMailboxDetails(): void
|
||||
{
|
||||
$page = ['draw' => 1, 'recordsTotal' => 2, 'recordsFiltered' => 2, 'data' => [
|
||||
[2 => 'john@example.org', 3 => 0, 4 => '', 7 => 'John'],
|
||||
[2 => 'gone@example.org', 3 => 0, 4 => '', 7 => 'Gone'],
|
||||
]];
|
||||
$result = $this->post('search/mailbox', self::REQUEST, ['script' => [
|
||||
self::answer('SSP::complex', [], $page),
|
||||
self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']),
|
||||
]]);
|
||||
|
||||
$this->assertSame([['get', 'mailbox_details', 'john@example.org'], ['get', 'mailbox_details', 'gone@example.org']], $result->callsTo('mailbox'));
|
||||
$this->assertSame(['draw' => 1, 'recordsTotal' => 2, 'recordsFiltered' => 2, 'data' => [['username' => 'john@example.org']]], $result->json());
|
||||
}
|
||||
|
||||
public function testEmptyPageIsPassedOn(): void
|
||||
{
|
||||
$page = ['draw' => 4, 'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => []];
|
||||
|
||||
$result = $this->post('search/mailbox', self::REQUEST, ['script' => [self::answer('SSP::complex', [], $page)]]);
|
||||
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
$this->assertSame($page, $result->json());
|
||||
}
|
||||
|
||||
private function searchCall(ApiResult $result): array
|
||||
{
|
||||
$calls = $result->callsTo('SSP::complex');
|
||||
$this->assertCount(1, $calls, $result->body);
|
||||
$this->assertNotCalled($result, 'SSP::simple');
|
||||
return $calls[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Settings a mailbox user changes on its page: time-limited aliases, Pushover, tagged mail, TLS, quarantine, spam score */
|
||||
class MailboxSettingsRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testTimeLimitedAliases(): void
|
||||
{
|
||||
$body = ['username' => 'john@example.org', 'domain' => 'example.org', 'validity' => 720];
|
||||
$this->assertCalled($this->post('add/time_limited_alias', $body), 'mailbox', ['add', 'time_limited_alias', $body]);
|
||||
|
||||
$edit = $this->post('edit/time_limited_alias', ['items' => ['kahd.osuwe@example.org'], 'attr' => ['validity' => 24]]);
|
||||
$this->assertCalled($edit, 'mailbox', ['edit', 'time_limited_alias', ['address' => ['kahd.osuwe@example.org'], 'validity' => 24]]);
|
||||
|
||||
$delete = $this->post('delete/time_limited_alias', ['kahd.osuwe@example.org']);
|
||||
$this->assertCalled($delete, 'mailbox', ['delete', 'time_limited_alias', ['address' => ['kahd.osuwe@example.org']]]);
|
||||
}
|
||||
|
||||
public function testListTimeLimitedAliases(): void
|
||||
{
|
||||
$aliases = [['address' => 'kahd.osuwe@example.org', 'goto' => 'john@example.org', 'permanent' => 0]];
|
||||
|
||||
$result = $this->get('get/time_limited_aliases/john@example.org', ['script' => [self::answer('mailbox', ['get', 'time_limited_aliases'], $aliases)]]);
|
||||
|
||||
$this->assertSame([['get', 'time_limited_aliases', 'john@example.org']], $result->callsTo('mailbox'));
|
||||
$this->assertSame($aliases, $result->json());
|
||||
$this->assertSame('{}', $this->get('get/time_limited_aliases/jane@example.org')->body);
|
||||
}
|
||||
|
||||
public function testPushover(): void
|
||||
{
|
||||
$attr = ['key' => str_repeat('a', 30), 'token' => str_repeat('b', 30), 'active' => 1];
|
||||
$this->assertCalled($this->post('edit/pushover', ['items' => ['john@example.org'], 'attr' => $attr]), 'pushover', ['edit', ['username' => ['john@example.org']] + $attr]);
|
||||
|
||||
$this->assertCalled($this->post('edit/pushover-test', ['items' => ['john@example.org'], 'attr' => []]), 'pushover', ['test', ['username' => ['john@example.org']]]);
|
||||
}
|
||||
|
||||
public function testMailboxUserSettingsGetTheMailboxesAsUsername(): void
|
||||
{
|
||||
$routes = [
|
||||
'delimiter_action' => ['tagged_mail_handler' => 'subfolder'],
|
||||
'tls_policy' => ['tls_enforce_in' => 1],
|
||||
'quarantine_notification' => ['quarantine_notification' => 'daily'],
|
||||
'quarantine_category' => ['quarantine_category' => 'all'],
|
||||
'spam_score' => ['spam_score' => '6,12'],
|
||||
];
|
||||
foreach ($routes as $type => $attr) {
|
||||
$route = 'edit/' . ($type === 'spam_score' ? 'spam-score' : $type);
|
||||
$result = $this->post($route, ['items' => 'john@example.org', 'attr' => $attr]);
|
||||
$this->assertCalled($result, 'mailbox', ['edit', $type, ['username' => ['john@example.org']] + $attr]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheUiSendsItsOwnAddressAsItem(): void
|
||||
{
|
||||
$result = $this->postForm('edit/delimiter_action', ['items' => '["user@example.org"]', 'attr' => '{"tagged_mail_handler":"subject","csrf_token":"t"}'], ['session' => self::USER]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'delimiter_action', ['username' => ['user@example.org'], 'tagged_mail_handler' => 'subject']]);
|
||||
}
|
||||
|
||||
public function testSpamScoreWithoutSpaces(): void
|
||||
{
|
||||
$mailbox = $this->get('get/spam-score/john@example.org', ['script' => [self::answer('mailbox', ['get', 'spam_score'], '6, 12')]]);
|
||||
$this->assertSame([['get', 'spam_score', 'john@example.org']], $mailbox->callsTo('mailbox'));
|
||||
$this->assertSame(['score' => '6,12'], $mailbox->json());
|
||||
|
||||
$default = $this->get('get/spam-score', ['script' => [self::answer('mailbox', ['get', 'spam_score'], '8, 15')]]);
|
||||
$this->assertSame([['get', 'spam_score', null]], $default->callsTo('mailbox'));
|
||||
$this->assertSame(['score' => '8,15'], $default->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/spam-score/jane@example.org', ['script' => [self::answer('mailbox', [], false)]])->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Rate limits of mailboxes and the reset of rate limit counters */
|
||||
class RatelimitRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testRatelimitsOfAllMailboxesAddTheMailbox(): void
|
||||
{
|
||||
$result = $this->get('get/rl-mbox/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'jane@example.org']),
|
||||
self::answer('ratelimit', ['get', 'mailbox', 'john@example.org'], ['value' => '100', 'frame' => 'h']),
|
||||
self::answer('ratelimit', ['get', 'mailbox', 'jane@example.org'], false),
|
||||
]]);
|
||||
|
||||
$this->assertSame([['value' => '100', 'frame' => 'h', 'mailbox' => 'john@example.org']], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/rl-mbox/all')->body);
|
||||
}
|
||||
|
||||
public function testRatelimitOfAMailbox(): void
|
||||
{
|
||||
$result = $this->get('get/rl-mbox/john@example.org', ['script' => [self::answer('ratelimit', [], ['value' => '100', 'frame' => 'h'])]]);
|
||||
|
||||
$this->assertSame([['get', 'mailbox', 'john@example.org']], $result->callsTo('ratelimit'));
|
||||
$this->assertSame(['value' => '100', 'frame' => 'h'], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/rl-mbox/jane@example.org')->body);
|
||||
}
|
||||
|
||||
public function testEditRatelimit(): void
|
||||
{
|
||||
$result = $this->post('edit/rl-mbox', ['items' => ['john@example.org'], 'attr' => ['rl_value' => 100, 'rl_frame' => 'h']]);
|
||||
|
||||
$this->assertCalled($result, 'ratelimit', ['edit', 'mailbox', ['object' => ['john@example.org'], 'rl_value' => 100, 'rl_frame' => 'h']]);
|
||||
}
|
||||
|
||||
public function testDeleteRatelimitHashAnswersTheRawResult(): void
|
||||
{
|
||||
$deleted = $this->post('delete/rlhash', ['RLabc123'], ['script' => [self::answer('ratelimit', [], true, [self::message('success', 'hash_deleted')])]]);
|
||||
$this->assertSame([['delete', null, 'RLabc123']], $deleted->callsTo('ratelimit'));
|
||||
$this->assertSame('1', $deleted->body);
|
||||
|
||||
$missing = $this->post('delete/rlhash', ['RLabc123'], ['script' => [self::answer('ratelimit', [], false, [self::message('warning', 'hash_not_found')])]]);
|
||||
$this->assertSame('', $missing->body);
|
||||
|
||||
$this->assertSame([['delete', null, 'RLabcRLdef']], $this->post('delete/rlhash', ['RLabc', 'RLdef'])->callsTo('ratelimit'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Mailboxes;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Resources (rooms, equipment, groups) */
|
||||
class ResourceRoutesTest extends ApiTestCase
|
||||
{
|
||||
public function testAddResource(): void
|
||||
{
|
||||
$body = ['domain' => 'example.org', 'description' => 'Meeting Room 1', 'kind' => 'location', 'multiple_bookings' => 0, 'active' => 1];
|
||||
|
||||
$this->assertCalled($this->post('add/resource', $body), 'mailbox', ['add', 'resource', $body]);
|
||||
}
|
||||
|
||||
public function testGetAllResourcesWalksAllDomains(): void
|
||||
{
|
||||
$result = $this->get('get/resource/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'resources', 'example.org'], ['room1@example.org', 'gone@example.org']),
|
||||
self::answer('mailbox', ['get', 'resource_details', 'room1@example.org'], ['name' => 'room1@example.org']),
|
||||
self::answer('mailbox', ['get', 'resource_details', 'gone@example.org'], false),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'resources', 'example.com']);
|
||||
$this->assertSame([['name' => 'room1@example.org']], $result->json());
|
||||
$this->assertSame('{}', $this->get('get/resource/all')->body);
|
||||
}
|
||||
|
||||
public function testGetOneResource(): void
|
||||
{
|
||||
$result = $this->get('get/resource/room1@example.org', ['script' => [self::answer('mailbox', ['get', 'resource_details'], ['name' => 'room1@example.org'])]]);
|
||||
|
||||
$this->assertSame([['get', 'resource_details', 'room1@example.org']], $result->callsTo('mailbox'));
|
||||
$this->assertSame(['name' => 'room1@example.org'], $result->json());
|
||||
}
|
||||
|
||||
public function testEditAndDeleteResources(): void
|
||||
{
|
||||
$edit = $this->post('edit/resource', ['items' => ['room1@example.org'], 'attr' => ['multiple_bookings' => -1]]);
|
||||
$this->assertCalled($edit, 'mailbox', ['edit', 'resource', ['name' => ['room1@example.org'], 'multiple_bookings' => -1]]);
|
||||
|
||||
$delete = $this->post('delete/resource', ['room1@example.org']);
|
||||
$this->assertCalled($delete, 'mailbox', ['delete', 'resource', ['name' => ['room1@example.org']]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Modules;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes identity-provider, identity-provider-test, identity-provider-sync and identity-link */
|
||||
class IdentityRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const API = 'Mailcow\Identity\IdentityModule->api->';
|
||||
/** The Identity Provider settings that prerequisites.inc.php loads into $iam_settings */
|
||||
private const IAM_SETTINGS = ['iam_settings' => [
|
||||
'authsource' => 'keycloak',
|
||||
'server_url' => 'https://auth.example.org',
|
||||
'realm' => 'mailcow',
|
||||
'client_id' => 'mailcow',
|
||||
'client_secret' => 'client-secret-value',
|
||||
'bindpass' => 'bind-password',
|
||||
'access_token' => 'cached-token',
|
||||
'redirect_url_extra' => ['https://webmail.example.org'],
|
||||
'login_provisioning' => true,
|
||||
]];
|
||||
private const SETTINGS = [
|
||||
'authsource' => 'keycloak',
|
||||
'server_url' => 'https://auth.example.org',
|
||||
'realm' => 'mailcow',
|
||||
'client_id' => 'mailcow',
|
||||
'client_secret' => 'client-secret-value',
|
||||
'bindpass' => 'bind-password',
|
||||
'access_token' => 'cached-token',
|
||||
'redirect_url_extra' => ['https://webmail.example.org'],
|
||||
'login_provisioning' => true,
|
||||
];
|
||||
|
||||
public function testSettingsForReadWriteKeys(): void
|
||||
{
|
||||
$result = $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS]);
|
||||
|
||||
$this->assertSame(self::SETTINGS, $result->json());
|
||||
$this->assertNotCalled($result, self::API . 'getStatus');
|
||||
}
|
||||
|
||||
public function testReadOnlyKeysGetNoSecrets(): void
|
||||
{
|
||||
$result = $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS, 'session' => self::API_RO]);
|
||||
|
||||
$this->assertSame(array_replace(self::SETTINGS, ['client_secret' => '*', 'bindpass' => '*', 'access_token' => '*']), $result->json());
|
||||
}
|
||||
|
||||
public function testSettingsOnlyForAdmins(): void
|
||||
{
|
||||
foreach ([self::DOMAIN_ADMIN, self::USER] as $session) {
|
||||
$this->assertSame('{}', $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS, 'session' => $session])->body);
|
||||
}
|
||||
}
|
||||
|
||||
public function testOtherObjectsAnswerTheSettingsToo(): void
|
||||
{
|
||||
$result = $this->get('get/identity-provider/anything', ['globals' => self::IAM_SETTINGS]);
|
||||
|
||||
$this->assertSame(self::SETTINGS, $result->json());
|
||||
}
|
||||
|
||||
public function testSyncStatus(): void
|
||||
{
|
||||
$status = ['provider' => 'keycloak', 'can_sync' => true, 'sync_enabled' => false, 'counts' => ['accounts' => 2], 'runs' => []];
|
||||
|
||||
$result = $this->get('get/identity-provider/sync-status', ['session' => self::API_RO, 'script' => [self::answer(self::API . 'getStatus', [], $status)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getStatus', []);
|
||||
$this->assertSame($status, $result->json());
|
||||
}
|
||||
|
||||
public function testLink(): void
|
||||
{
|
||||
$link = ['provider' => 'keycloak', 'external_id' => '8d5da28d-7541-428b-8356-a9704f73e7f4', 'idp_disabled' => 0, 'missing_since' => null];
|
||||
|
||||
$result = $this->get('get/identity-link/alice@example.org', ['script' => [self::answer(self::API . 'getLink', ['alice@example.org'], $link)]]);
|
||||
$this->assertCalled($result, self::API . 'getLink', ['alice@example.org']);
|
||||
$this->assertSame($link, $result->json());
|
||||
|
||||
$none = $this->get('get/identity-link');
|
||||
$this->assertCalled($none, self::API . 'getLink', ['']);
|
||||
$this->assertSame('{}', $none->body);
|
||||
}
|
||||
|
||||
public function testEditSettings(): void
|
||||
{
|
||||
$attr = ['authsource' => 'ldap', 'host' => 'ldap.example.org', 'basedn' => 'dc=example,dc=org', 'binddn' => 'cn=mailcow', 'bindpass' => 'secret'];
|
||||
$messages = [self::message('success', ['object_modified', ''], ['identity_provider', 'edit', ['bindpass' => '*']])];
|
||||
|
||||
$result = $this->post('edit/identity-provider', ['items' => ['identity-provider'], 'attr' => $attr],
|
||||
['script' => [self::answer('identity_provider', ['edit'], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, 'identity_provider', ['edit', $attr]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testConnectionTestAnswersWithoutDetails(): void
|
||||
{
|
||||
$attr = ['authsource' => 'keycloak', 'server_url' => 'https://auth.example.org', 'realm' => 'mailcow', 'client_id' => 'mailcow', 'client_secret' => 'secret'];
|
||||
|
||||
$ok = $this->post('edit/identity-provider-test', ['attr' => $attr], ['script' => [self::answer('identity_provider', ['test'], true)]]);
|
||||
$this->assertCalled($ok, 'identity_provider', ['test', $attr]);
|
||||
$this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $ok->json());
|
||||
|
||||
$failed = $this->post('edit/identity-provider-test', ['attr' => $attr], ['script' => [self::answer('identity_provider', ['test'], false)]]);
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $failed->json());
|
||||
}
|
||||
|
||||
public function testRequestSync(): void
|
||||
{
|
||||
$result = $this->post('edit/identity-provider-sync', ['items' => ['sync'], 'attr' => ['mode' => 'preview']]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'requestRun', [['mode' => 'preview']]);
|
||||
}
|
||||
|
||||
public function testDeleteSettingsIgnoresTheBody(): void
|
||||
{
|
||||
$result = $this->post('delete/identity-provider', ['anything']);
|
||||
|
||||
$this->assertSame([['delete']], $result->callsTo('identity_provider'));
|
||||
}
|
||||
|
||||
public function testDeleteLinks(): void
|
||||
{
|
||||
$messages = [self::message('success', ['iam_link_removed', 'alice@example.org'], ['identity_provider', 'delete', 'link', ['alice@example.org']])];
|
||||
|
||||
$result = $this->post('delete/identity-link', ['alice@example.org'], ['script' => [self::answer(self::API . 'unlink', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'unlink', [['alice@example.org']]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Modules;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes mailbox-group: json_api.php hands them to the MailboxGroup module and passes its answers through */
|
||||
class MailboxGroupRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const API = 'Mailcow\MailboxGroup\MailboxGroupModule->api->';
|
||||
|
||||
public function testAdd(): void
|
||||
{
|
||||
$attr = ['domain' => 'example.org', 'name' => 'Sales', 'members' => ['alice@example.org'], 'sender_allowed' => 1];
|
||||
$messages = [
|
||||
self::message('success', ['group_added', 'Sales'], ['mailbox_group', 'add', 'group', $attr]),
|
||||
self::message('warning', ['group_imap_grant_failed', 'shared@example.org'], ['mailbox_group', 'add', 'group', $attr]),
|
||||
];
|
||||
|
||||
$result = $this->post('add/mailbox-group', $attr, ['script' => [self::answer(self::API . 'addGroup', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'addGroup', [$attr]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testGetAll(): void
|
||||
{
|
||||
$groups = [['id' => 7, 'domain' => 'example.org', 'name' => 'Sales', 'can_edit' => true]];
|
||||
|
||||
$result = $this->get('get/mailbox-group/all', ['script' => [self::answer(self::API . 'getGroups', [], $groups)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getGroups', [null]);
|
||||
$this->assertSame($groups, $result->json());
|
||||
}
|
||||
|
||||
public function testGetAllOfDomains(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox-group/all/example.org,example.com');
|
||||
|
||||
$this->assertCalled($result, self::API . 'getGroups', ['example.org,example.com']);
|
||||
}
|
||||
|
||||
public function testNoGroupsAnswerAnEmptyObject(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox-group/all', ['script' => [self::answer(self::API . 'getGroups', [], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testGetMemberships(): void
|
||||
{
|
||||
$memberships = ['groups' => [['id' => 7, 'name' => 'Sales', 'source' => 'manual']], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
|
||||
|
||||
$result = $this->get('get/mailbox-group/member/alice@example.org', ['script' => [self::answer(self::API . 'getMemberships', [], $memberships)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getMemberships', ['alice@example.org']);
|
||||
$this->assertSame($memberships, $result->json());
|
||||
$this->assertNotCalled($result, self::API . 'getGroup');
|
||||
}
|
||||
|
||||
public function testGetMembershipsWithoutMailbox(): void
|
||||
{
|
||||
$result = $this->get('get/mailbox-group/member');
|
||||
|
||||
$this->assertCalled($result, self::API . 'getMemberships', ['']);
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testGetOne(): void
|
||||
{
|
||||
$group = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales'];
|
||||
|
||||
$result = $this->get('get/mailbox-group/7', ['script' => [self::answer(self::API . 'getGroup', ['7'], $group)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getGroup', ['7']);
|
||||
$this->assertSame($group, $result->json());
|
||||
}
|
||||
|
||||
public function testEdit(): void
|
||||
{
|
||||
$attr = ['add_members' => ['carol@example.org'], 'remove_members' => 'bob@example.org'];
|
||||
|
||||
$result = $this->post('edit/mailbox-group', ['items' => [7, '8'], 'attr' => $attr]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'editGroups', [[7, '8'], $attr]);
|
||||
}
|
||||
|
||||
public function testEditWithASingleIdOrWithoutItems(): void
|
||||
{
|
||||
$single = $this->post('edit/mailbox-group', ['items' => 7, 'attr' => ['active' => 0]]);
|
||||
$this->assertCalled($single, self::API . 'editGroups', [[7], ['active' => 0]]);
|
||||
|
||||
$none = $this->post('edit/mailbox-group', ['attr' => ['id' => [9], 'active' => 0]]);
|
||||
$this->assertCalled($none, self::API . 'editGroups', [[], ['id' => [9], 'active' => 0]]);
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$messages = [
|
||||
self::message('success', ['group_removed', 'Sales'], ['mailbox_group', 'delete', 'group', ['id' => [7, 'x']]]),
|
||||
self::message('danger', 'access_denied', ['mailbox_group', 'delete', 'group', ['id' => [7, 'x']]]),
|
||||
];
|
||||
|
||||
$result = $this->post('delete/mailbox-group', [7, 'x'], ['script' => [self::answer(self::API . 'deleteGroups', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'deleteGroups', [[7, 'x']]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Modules;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** Routes syncjob, syncjobs, syncjob_source, syncjob_options and imapsync_settings: handed to the Imapsync module */
|
||||
class SyncJobRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const API = 'Mailcow\Imapsync\ImapsyncModule->api->';
|
||||
|
||||
public function testAddJob(): void
|
||||
{
|
||||
$attr = ['username' => 'alice@example.org', 'source_id' => 3, 'user1' => 'alice@example.com', 'password1' => 'secret', 'mins_interval' => 20];
|
||||
$messages = [self::message('success', ['mailbox_modified', 'alice@example.org'], ['syncjob', 'add', 'job', ['password1' => '*']])];
|
||||
|
||||
$result = $this->post('add/syncjob', $attr, ['script' => [self::answer(self::API . 'addJob', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'addJob', [$attr]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testAddSource(): void
|
||||
{
|
||||
$attr = ['name' => 'Old provider', 'host' => 'imap.example.com', 'port' => 993, 'enc' => 'SSL', 'scope' => 'domain', 'domains' => ['example.org']];
|
||||
|
||||
$result = $this->post('add/syncjob_source', $attr);
|
||||
|
||||
$this->assertCalled($result, self::API . 'addSource', [$attr]);
|
||||
}
|
||||
|
||||
public function testAllJobsOfAnApiKey(): void
|
||||
{
|
||||
$jobs = [['id' => 12, 'user2' => 'alice@example.org', 'log' => 'done']];
|
||||
|
||||
$result = $this->get('get/syncjobs/all', ['script' => [self::answer(self::API . 'getAllJobs', [], $jobs)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getAllJobs', [null, []]);
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
$this->assertSame($jobs, $result->json());
|
||||
}
|
||||
|
||||
public function testAllJobsWithoutLog(): void
|
||||
{
|
||||
$result = $this->get('get/syncjobs/all/no_log');
|
||||
|
||||
$this->assertCalled($result, self::API . 'getAllJobs', ['no_log', []]);
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
public function testAllJobsOfADomainAdminAreTheJobsOfHisMailboxes(): void
|
||||
{
|
||||
$result = $this->get('get/syncjobs/all/no_log', ['session' => self::DOMAIN_ADMIN, 'script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['alice@example.org', 'bob@example.org']),
|
||||
self::answer('mailbox', ['get', 'mailboxes', 'example.com'], false),
|
||||
]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getAllJobs', ['no_log', ['alice@example.org', 'bob@example.org']]);
|
||||
}
|
||||
|
||||
public function testAllJobsOfAUser(): void
|
||||
{
|
||||
$result = $this->get('get/syncjobs/all', ['session' => self::USER, 'script' => [self::answer('mailbox', ['get', 'domains'], false)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getAllJobs', [null, []]);
|
||||
}
|
||||
|
||||
public function testJobsOfAMailbox(): void
|
||||
{
|
||||
$withLog = $this->get('get/syncjobs/alice@example.org');
|
||||
$this->assertCalled($withLog, self::API . 'getJobs', ['alice@example.org', null]);
|
||||
$this->assertNotCalled($withLog, self::API . 'getAllJobs');
|
||||
|
||||
$withoutLog = $this->get('get/syncjobs/alice@example.org/no_log');
|
||||
$this->assertCalled($withoutLog, self::API . 'getJobs', ['alice@example.org', 'no_log']);
|
||||
|
||||
$own = $this->get('get/syncjobs');
|
||||
$this->assertCalled($own, self::API . 'getJobs', [null, null]);
|
||||
}
|
||||
|
||||
public function testSources(): void
|
||||
{
|
||||
$sources = [['id' => 3, 'name' => 'Old provider', 'has_client_secret' => false]];
|
||||
|
||||
$all = $this->get('get/syncjob_source/all', ['script' => [self::answer(self::API . 'getSources', [], $sources)]]);
|
||||
$this->assertCalled($all, self::API . 'getSources', []);
|
||||
$this->assertSame($sources, $all->json());
|
||||
|
||||
$one = $this->get('get/syncjob_source/3', ['script' => [self::answer(self::API . 'getSource', ['3'], $sources[0])]]);
|
||||
$this->assertCalled($one, self::API . 'getSource', ['3']);
|
||||
$this->assertSame($sources[0], $one->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/syncjob_source/99')->body);
|
||||
}
|
||||
|
||||
public function testOptions(): void
|
||||
{
|
||||
$options = ['folder' => true, 'syncinternaldates' => false, 'sslargs1' => '^SSL_verify_mode=[01]$'];
|
||||
|
||||
$result = $this->get('get/syncjob_options', ['session' => self::USER, 'script' => [self::answer(self::API . 'getOptions', [], $options)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'getOptions', []);
|
||||
$this->assertSame($options, $result->json());
|
||||
}
|
||||
|
||||
public function testSettings(): void
|
||||
{
|
||||
$settings = ['max_parallel' => 3, 'max_bytes_per_second' => 2048000, 'max_kb_per_second' => 2000];
|
||||
|
||||
$result = $this->get('get/imapsync_settings', ['script' => [self::answer(self::API . 'getSettings', [], $settings)]]);
|
||||
$this->assertCalled($result, self::API . 'getSettings', []);
|
||||
$this->assertSame($settings, $result->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/imapsync_settings', ['session' => self::DOMAIN_ADMIN, 'script' => [self::answer(self::API . 'getSettings', [], [])]])->body);
|
||||
}
|
||||
|
||||
public function testEditJobs(): void
|
||||
{
|
||||
$result = $this->post('edit/syncjob', ['items' => [12, '13'], 'attr' => ['active' => 0, 'mins_interval' => 60]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'editJobs', [[12, '13'], ['active' => 0, 'mins_interval' => 60]]);
|
||||
$this->assertNotCalled($result, self::API . 'runNext');
|
||||
|
||||
$other = $this->post('edit/syncjob/anything', ['items' => [12], 'attr' => ['active' => 1]]);
|
||||
$this->assertCalled($other, self::API . 'editJobs', [[12], ['active' => 1]]);
|
||||
}
|
||||
|
||||
public function testRunNext(): void
|
||||
{
|
||||
$messages = [
|
||||
self::message('danger', ['imapsync_job_running', 13], ['syncjob', 'edit', 'run_next', ['id' => [12, 13]]]),
|
||||
self::message('success', ['imapsync_run_next_done', 1], ['syncjob', 'edit', 'run_next', ['id' => [12, 13]]]),
|
||||
];
|
||||
|
||||
$result = $this->post('edit/syncjob/run_next', ['items' => [12, 13], 'attr' => ['ignored' => 1]],
|
||||
['script' => [self::answer(self::API . 'runNext', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'runNext', [[12, 13]]);
|
||||
$this->assertNotCalled($result, self::API . 'editJobs');
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
|
||||
public function testRunNextWithoutMovedJobs(): void
|
||||
{
|
||||
$result = $this->post('edit/syncjob/run_next', ['items' => [99]], ['script' => [self::answer(self::API . 'runNext', [], false)]]);
|
||||
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $result->json());
|
||||
}
|
||||
|
||||
public function testEditSettings(): void
|
||||
{
|
||||
$result = $this->post('edit/imapsync_settings', ['items' => ['ignored'], 'attr' => ['max_parallel' => 4, 'max_kb_per_second' => 2000]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'editSettings', [['max_parallel' => 4, 'max_kb_per_second' => 2000]]);
|
||||
}
|
||||
|
||||
public function testEditSources(): void
|
||||
{
|
||||
$attr = ['description' => 'Migration finished', 'scope' => 'user', 'users' => ['alice@example.org']];
|
||||
|
||||
$result = $this->post('edit/syncjob_source', ['items' => [3], 'attr' => $attr]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'editSources', [[3], $attr]);
|
||||
$this->assertNotCalled($result, self::API . 'refreshSourceToken');
|
||||
}
|
||||
|
||||
public function testRefreshToken(): void
|
||||
{
|
||||
$result = $this->post('edit/syncjob_source/refresh_token', ['items' => [5, 6], 'attr' => []]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'refreshSourceToken', [[5, 6]]);
|
||||
$this->assertNotCalled($result, self::API . 'editSources');
|
||||
}
|
||||
|
||||
public function testDeleteJobs(): void
|
||||
{
|
||||
$result = $this->post('delete/syncjob', [12, '13']);
|
||||
|
||||
$this->assertCalled($result, self::API . 'deleteJobs', [[12, '13']]);
|
||||
}
|
||||
|
||||
public function testDeleteSources(): void
|
||||
{
|
||||
$messages = [self::message('danger', ['imapsync_source_in_use', 'Old provider'], ['syncjob', 'delete', 'source', ['id' => [3]]])];
|
||||
|
||||
$result = $this->post('delete/syncjob_source', [3], ['script' => [self::answer(self::API . 'deleteSources', [], true, $messages)]]);
|
||||
|
||||
$this->assertCalled($result, self::API . 'deleteSources', [[3]]);
|
||||
$this->assertSame($messages, $result->json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/** data/web/api/openapi.yaml against json_api.php: every route documented, every documented route real, the spec consistent */
|
||||
class OpenApiSpecTest extends ApiTestCase
|
||||
{
|
||||
private const SPEC = __DIR__ . '/../../../data/web/api/openapi.yaml';
|
||||
private const JSON_API = __DIR__ . '/../../../data/web/json_api.php';
|
||||
|
||||
/** Routes of the mailcow UI that an API client cannot use, with the reason */
|
||||
private const UNDOCUMENTED = [
|
||||
// WebAuthn ceremonies of the browser: the challenge lives in the UI session
|
||||
'add/fido2-registration' => 'WebAuthn',
|
||||
'add/webauthn-tfa-registration' => 'WebAuthn',
|
||||
'get/fido2-registration' => 'WebAuthn',
|
||||
'get/fido2-get-args' => 'WebAuthn',
|
||||
'get/webauthn-tfa-registration' => 'WebAuthn',
|
||||
'get/webauthn-tfa-get-args' => 'WebAuthn',
|
||||
// the own account of a UI session (domain admin, mailbox user): an API key gets an empty answer
|
||||
'edit/self' => 'own account',
|
||||
// renames a FIDO2 key of the logged-in account, the API account has none
|
||||
'edit/fido2-fn' => 'own account',
|
||||
];
|
||||
|
||||
private static ?array $spec = null;
|
||||
|
||||
private static function spec(): array
|
||||
{
|
||||
return self::$spec ??= Yaml::parseFile(self::SPEC);
|
||||
}
|
||||
|
||||
/** @return list<array{0: string, 1: string, 2: array}> path, method, operation */
|
||||
private static function operations(): array
|
||||
{
|
||||
$operations = [];
|
||||
foreach (self::spec()['paths'] as $path => $item) {
|
||||
foreach ($item as $method => $operation) {
|
||||
if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) {
|
||||
$operations[] = [$path, $method, $operation];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $operations;
|
||||
}
|
||||
|
||||
public function testEveryRouteIsDocumented(): void
|
||||
{
|
||||
$paths = array_keys(self::spec()['paths']);
|
||||
$missing = [];
|
||||
foreach (RouteMap::fromFile(self::JSON_API) as $route) {
|
||||
$covered = isset(self::UNDOCUMENTED[$route]) || !empty(array_filter($paths, fn(string $path): bool => RouteMap::covers($path, $route)));
|
||||
if (!$covered) {
|
||||
$missing[] = $route;
|
||||
}
|
||||
}
|
||||
$this->assertSame([], $missing, 'routes of json_api.php missing in openapi.yaml');
|
||||
}
|
||||
|
||||
public function testTheUndocumentedRoutesExist(): void
|
||||
{
|
||||
$this->assertSame([], array_values(array_diff(array_keys(self::UNDOCUMENTED), RouteMap::fromFile(self::JSON_API))));
|
||||
}
|
||||
|
||||
public function testEveryDocumentedRouteExists(): void
|
||||
{
|
||||
$unknown = [];
|
||||
foreach (self::operations() as [$path, $method, $operation]) {
|
||||
$route = preg_replace_callback('/\{([^}]+)\}/', fn(array $m): string => self::exampleOf($operation, $m[1]), $path);
|
||||
$action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0];
|
||||
$body = match ($action) {
|
||||
'edit' => ['items' => [], 'attr' => (object)[]],
|
||||
'delete' => [],
|
||||
default => (object)[],
|
||||
};
|
||||
$result = ($method === 'get') ? $this->get($route, ['allow_error' => true]) : $this->post($route, $body, ['allow_error' => true]);
|
||||
$answer = $result->json();
|
||||
if ($result->status === 404 && is_array($answer) && ($answer['type'] ?? '') === 'error') {
|
||||
$unknown[] = strtoupper($method) . ' ' . $path . ': ' . ($answer['msg'] ?? '');
|
||||
}
|
||||
}
|
||||
$this->assertSame([], $unknown, 'documented routes that json_api.php does not know');
|
||||
}
|
||||
|
||||
public function testMethodsFollowTheAction(): void
|
||||
{
|
||||
foreach (self::operations() as [$path, $method]) {
|
||||
$action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0];
|
||||
$this->assertSame(($action === 'get') ? 'get' : 'post', $method, $path);
|
||||
}
|
||||
}
|
||||
|
||||
public function testOperationsAreComplete(): void
|
||||
{
|
||||
$tags = array_column(self::spec()['tags'], 'name');
|
||||
$ids = [];
|
||||
foreach (self::operations() as [$path, $method, $operation]) {
|
||||
$where = strtoupper($method) . ' ' . $path;
|
||||
$this->assertNotEmpty($operation['summary'] ?? null, "$where: summary");
|
||||
$this->assertNotEmpty($operation['operationId'] ?? null, "$where: operationId");
|
||||
$ids[] = $operation['operationId'];
|
||||
$this->assertNotEmpty($operation['tags'] ?? [], "$where: tags");
|
||||
$this->assertSame([], array_values(array_diff($operation['tags'] ?? [], $tags)), "$where: tags not in the tag list");
|
||||
$this->assertArrayHasKey('200', $operation['responses'] ?? [], "$where: 200 response");
|
||||
$this->assertArrayHasKey('401', $operation['responses'] ?? [], "$where: 401 response");
|
||||
preg_match_all('/\{([^}]+)\}/', $path, $placeholders);
|
||||
$declared = array_column(array_filter($operation['parameters'] ?? [], fn(array $p): bool => ($p['in'] ?? '') === 'path'), 'name');
|
||||
sort($declared);
|
||||
$expected = $placeholders[1];
|
||||
sort($expected);
|
||||
$this->assertSame($expected, $declared, "$where: path parameters");
|
||||
}
|
||||
$this->assertSame(array_values(array_unique($ids)), $ids, 'operationIds must be unique');
|
||||
}
|
||||
|
||||
public function testEveryTagIsUsed(): void
|
||||
{
|
||||
$used = array_merge(...array_map(fn(array $op): array => $op[2]['tags'] ?? [], self::operations()));
|
||||
$this->assertSame([], array_values(array_diff(array_column(self::spec()['tags'], 'name'), $used)));
|
||||
}
|
||||
|
||||
public function testReferencesResolve(): void
|
||||
{
|
||||
$refs = [];
|
||||
$spec = self::spec();
|
||||
array_walk_recursive($spec, function (mixed $value, mixed $key) use (&$refs): void {
|
||||
if ($key === '$ref') {
|
||||
$refs[] = $value;
|
||||
}
|
||||
});
|
||||
foreach (array_unique($refs) as $ref) {
|
||||
$node = self::spec();
|
||||
foreach (explode('/', substr($ref, 2)) as $part) {
|
||||
$this->assertIsArray($node, $ref);
|
||||
$this->assertArrayHasKey(str_replace(['~1', '~0'], ['/', '~'], $part), $node, "unresolved $ref");
|
||||
$node = $node[str_replace(['~1', '~0'], ['/', '~'], $part)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function exampleOf(array $operation, string $name): string
|
||||
{
|
||||
foreach ($operation['parameters'] ?? [] as $parameter) {
|
||||
if (($parameter['name'] ?? null) === $name) {
|
||||
$example = $parameter['example'] ?? $parameter['schema']['example'] ?? $parameter['schema']['enum'][0] ?? 'test';
|
||||
return rawurlencode((string)$example);
|
||||
}
|
||||
}
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api;
|
||||
|
||||
/** What json_api.php does for every route: methods, read-only keys, request bodies, answers, unknown routes */
|
||||
class RequestHandlingTest extends ApiTestCase
|
||||
{
|
||||
public function testWritesNeedPost(): void
|
||||
{
|
||||
foreach (['add/alias', 'edit/alias', 'delete/alias'] as $route) {
|
||||
$result = $this->request('GET', $route, ['post' => ['attr' => '{}', 'items' => '[]']]);
|
||||
$this->assertApiError($result, 405, 'only POST method is allowed');
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
}
|
||||
}
|
||||
|
||||
public function testReadsNeedGet(): void
|
||||
{
|
||||
$this->assertApiError($this->post('get/domain/all'), 405, 'only GET method is allowed');
|
||||
}
|
||||
|
||||
public function testSearchNeedsPost(): void
|
||||
{
|
||||
$this->assertApiError($this->get('search/domain'), 405, 'only POST method is allowed');
|
||||
}
|
||||
|
||||
public function testReadOnlyKeysCannotWrite(): void
|
||||
{
|
||||
foreach (['add/alias' => ['address' => 'a@example.org'], 'edit/alias' => ['items' => [1], 'attr' => []], 'delete/alias' => [1]] as $route => $body) {
|
||||
$result = $this->post($route, $body, ['session' => self::API_RO]);
|
||||
$this->assertApiError($result, 403, 'API read/write access denied');
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
}
|
||||
}
|
||||
|
||||
public function testReadOnlyKeysCanRead(): void
|
||||
{
|
||||
$result = $this->get('get/alias/1', ['session' => self::API_RO]);
|
||||
|
||||
$this->assertSame(200, $result->status);
|
||||
$this->assertCalled($result, 'mailbox', ['get', 'alias_details', '1']);
|
||||
}
|
||||
|
||||
public function testWritesNeedALogin(): void
|
||||
{
|
||||
$this->assertApiError($this->post('add/alias', ['address' => 'a@example.org'], ['session' => self::NO_SESSION]), 403, 'API read/write access denied');
|
||||
}
|
||||
|
||||
public function testReadsWithoutLoginAnswerNothing(): void
|
||||
{
|
||||
// a wrong API key ends in sessions.inc.php with 401; without key and session the read routes are skipped
|
||||
$result = $this->get('get/domain/all', ['session' => self::NO_SESSION]);
|
||||
|
||||
$this->assertSame([200, ''], [$result->status, $result->body]);
|
||||
$this->assertNotCalled($result, 'mailbox');
|
||||
}
|
||||
|
||||
public function testUnknownRoutes(): void
|
||||
{
|
||||
$this->assertApiError($this->get('get/no-such-thing'), 404, 'route not found');
|
||||
$this->assertApiError($this->post('add/no-such-thing', ['x' => 1]), 404, 'route not found');
|
||||
$this->assertApiError($this->post('edit/no-such-thing', ['items' => [], 'attr' => []]), 404, 'route not found');
|
||||
$this->assertApiError($this->post('delete/no-such-thing', [1]), 404, 'route not found');
|
||||
$this->assertApiError($this->get('nothing/at/all'), 404, 'route not found');
|
||||
}
|
||||
|
||||
public function testInvalidJsonBody(): void
|
||||
{
|
||||
$this->assertApiError($this->post('add/alias', '{"address": '), 400, 'Request body doesn\'t contain valid json!');
|
||||
}
|
||||
|
||||
public function testJsonBodies(): void
|
||||
{
|
||||
// add: the body is attr
|
||||
$add = $this->post('add/alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']);
|
||||
$this->assertCalled($add, 'mailbox', ['add', 'alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']]);
|
||||
|
||||
// edit: {"items": ..., "attr": ...}
|
||||
$edit = $this->post('edit/alias', ['items' => [3, 4], 'attr' => ['active' => '0']]);
|
||||
$this->assertCalled($edit, 'mailbox', ['edit', 'alias', ['id' => [3, 4], 'active' => '0']]);
|
||||
|
||||
// delete: the body is the list of items
|
||||
$delete = $this->post('delete/alias', [3, 4]);
|
||||
$this->assertCalled($delete, 'mailbox', ['delete', 'alias', ['id' => [3, 4]]]);
|
||||
}
|
||||
|
||||
public function testFormBodiesOfTheUi(): void
|
||||
{
|
||||
$result = $this->postForm('edit/alias', ['items' => '[3]', 'attr' => '{"active":"1","csrf_token":"t"}'], ['session' => self::ADMIN]);
|
||||
|
||||
$this->assertCalled($result, 'mailbox', ['edit', 'alias', ['id' => [3], 'active' => '1']]);
|
||||
}
|
||||
|
||||
public function testMissingAttributes(): void
|
||||
{
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot find attributes in post data'], $this->request('POST', 'add/alias')->json());
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot find attributes in post data'], $this->request('POST', 'delete/alias')->json());
|
||||
}
|
||||
|
||||
public function testWriteAnswers(): void
|
||||
{
|
||||
$messages = [self::message('success', ['alias_added', 'a@example.org'], ['mailbox', 'add', 'alias'])];
|
||||
|
||||
$done = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], true, $messages)]]);
|
||||
$this->assertSame($messages, $done->json());
|
||||
|
||||
$silent = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], true)]]);
|
||||
$this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $silent->json());
|
||||
|
||||
$failed = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], false)]]);
|
||||
$this->assertSame(['type' => 'error', 'msg' => 'Cannot add item'], $failed->json());
|
||||
}
|
||||
|
||||
public function testReadAnswers(): void
|
||||
{
|
||||
$found = $this->get('get/alias/1', ['script' => [self::answer('mailbox', ['get', 'alias_details'], ['id' => 1, 'address' => 'a@example.org'])]]);
|
||||
$this->assertSame(['id' => 1, 'address' => 'a@example.org'], $found->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/alias/1')->body);
|
||||
}
|
||||
|
||||
public function testRequestsFromABrowserPageAreRefused(): void
|
||||
{
|
||||
$result = $this->get('get/domain/all', ['server' => ['HTTP_SEC_FETCH_DEST' => 'document']]);
|
||||
|
||||
$this->assertSame('', $result->body);
|
||||
$this->assertSame(['cors'], $result->callNames());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api;
|
||||
|
||||
/**
|
||||
* The routes json_api.php answers, read from its nested switch statements over $action, $category, $object and
|
||||
* $extra: "get/mailbox/all", "add/alias", "*" for a default case ("get/mailbox/*" = get/mailbox/<anything else>).
|
||||
* Default cases that answer 404 are no routes. Only the most specific routes are listed (get/mailbox/all, not get/mailbox).
|
||||
*/
|
||||
final class RouteMap
|
||||
{
|
||||
private const ROUTE_VARS = ['$action', '$category', '$object', '$extra'];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function fromFile(string $file): array
|
||||
{
|
||||
$tokens = array_values(array_filter(token_get_all((string)file_get_contents($file)),
|
||||
fn($t): bool => !is_array($t) || !in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)));
|
||||
$stack = [];
|
||||
$depth = 0;
|
||||
$pending = null;
|
||||
$routes = [];
|
||||
foreach ($tokens as $i => $token) {
|
||||
$id = is_array($token) ? $token[0] : $token;
|
||||
if ($id === T_SWITCH) {
|
||||
$isVar = ($tokens[$i + 1] ?? null) === '(' && is_array($tokens[$i + 2] ?? null) && $tokens[$i + 2][0] === T_VARIABLE && ($tokens[$i + 3] ?? null) === ')';
|
||||
$pending = ($isVar && in_array($tokens[$i + 2][1], self::ROUTE_VARS, true)) ? $tokens[$i + 2][1] : '';
|
||||
} elseif ($id === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
|
||||
$depth++;
|
||||
if ($pending !== null) {
|
||||
$stack[] = ['var' => $pending, 'depth' => $depth, 'label' => null];
|
||||
$pending = null;
|
||||
}
|
||||
} elseif ($id === '}') {
|
||||
if (!empty($stack) && end($stack)['depth'] === $depth) {
|
||||
array_pop($stack);
|
||||
}
|
||||
$depth--;
|
||||
} elseif (($id === T_CASE || $id === T_DEFAULT) && !empty($stack) && end($stack)['depth'] === $depth) {
|
||||
$label = ($id === T_DEFAULT) ? '*' : self::literal($tokens[$i + 1] ?? null);
|
||||
$stack[count($stack) - 1]['label'] = $label;
|
||||
if (end($stack)['var'] === '' || $label === null || ($id === T_DEFAULT && self::answers404($tokens, $i))) {
|
||||
continue;
|
||||
}
|
||||
$route = [];
|
||||
foreach ($stack as $switch) {
|
||||
if ($switch['var'] !== '') {
|
||||
$route[] = $switch['label'];
|
||||
}
|
||||
}
|
||||
$routes[implode('/', $route)] = true;
|
||||
}
|
||||
}
|
||||
$routes = array_keys($routes);
|
||||
return array_values(array_filter($routes, function (string $route) use ($routes): bool {
|
||||
foreach ($routes as $other) {
|
||||
if (str_starts_with($other, $route . '/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $documented (an OpenAPI path like /api/v1/get/mailbox/{id}) covers $route: same leading segments, a
|
||||
* {parameter} matches anything, trailing "*" of the route are optional (add/mailbox/* = add/mailbox)
|
||||
*/
|
||||
public static function covers(string $documented, string $route): bool
|
||||
{
|
||||
$doc = explode('/', trim(preg_replace('#^/?api/v1/#', '', $documented), '/'));
|
||||
$segments = explode('/', $route);
|
||||
while (count($segments) > count($doc) && end($segments) === '*') {
|
||||
array_pop($segments);
|
||||
}
|
||||
if (count($segments) > count($doc)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($segments as $i => $segment) {
|
||||
$isParam = (bool)preg_match('/^\{.+\}$/', $doc[$i]);
|
||||
if ($segment === '*' ? !$isParam : ($segment !== $doc[$i] && !$isParam)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function literal(mixed $token): ?string
|
||||
{
|
||||
return (is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) ? substr($token[1], 1, -1) : null;
|
||||
}
|
||||
|
||||
/** A default case whose code (up to the next case of its switch) sets the status 404 */
|
||||
private static function answers404(array $tokens, int $start): bool
|
||||
{
|
||||
$level = 0;
|
||||
for ($i = $start + 1; $i < count($tokens); $i++) {
|
||||
$id = is_array($tokens[$i]) ? $tokens[$i][0] : $tokens[$i];
|
||||
if ($id === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
|
||||
$level++;
|
||||
} elseif ($id === '}') {
|
||||
if ($level-- === 0) {
|
||||
return false;
|
||||
}
|
||||
} elseif ($level === 0 && ($id === T_CASE || $id === T_DEFAULT)) {
|
||||
return false;
|
||||
} elseif ($id === T_STRING && $tokens[$i][1] === 'http_response_code' && is_array($tokens[$i + 2] ?? null) && $tokens[$i + 2][1] === '404') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Routing;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** add/edit/delete/get alias */
|
||||
class AliasRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const SALES = ['id' => 12, 'address' => 'sales@example.org', 'goto' => 'anna@example.org,ben@example.org', 'active' => 1];
|
||||
private const INFO = ['id' => 13, 'address' => 'info@example.org', 'goto' => 'anna@example.org', 'active' => 1];
|
||||
private const SHOP = ['id' => 20, 'address' => 'shop@example.net', 'goto' => 'ben@example.org', 'active' => 0];
|
||||
|
||||
public function testAddPassesTheBody(): void
|
||||
{
|
||||
$attr = [
|
||||
'address' => "sales@example.org, info@example.org",
|
||||
'goto' => 'anna@example.org,ben@example.org',
|
||||
'active' => '1',
|
||||
'sogo_visible' => 1,
|
||||
'internal' => '0',
|
||||
'sender_allowed' => true,
|
||||
'public_comment' => 'Sales team',
|
||||
];
|
||||
|
||||
$result = $this->post('add/alias', $attr + ['csrf_token' => 'x']);
|
||||
|
||||
$this->assertSame([['add', 'alias', $attr]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testEditMergesItemsAndAttributes(): void
|
||||
{
|
||||
$list = $this->post('edit/alias', ['items' => [12, 'info@example.org'], 'attr' => ['goto_null' => '1', 'active' => '0']]);
|
||||
$this->assertCalled($list, 'mailbox', ['edit', 'alias', ['id' => [12, 'info@example.org'], 'goto_null' => '1', 'active' => '0']]);
|
||||
|
||||
$single = $this->post('edit/alias', ['items' => 12, 'attr' => ['expand_alias' => true]]);
|
||||
$this->assertCalled($single, 'mailbox', ['edit', 'alias', ['id' => [12], 'expand_alias' => true]]);
|
||||
}
|
||||
|
||||
public function testDeleteTakesIdsOrAddresses(): void
|
||||
{
|
||||
$result = $this->post('delete/alias', [12, 'info@example.org']);
|
||||
|
||||
$this->assertSame([['delete', 'alias', ['id' => [12, 'info@example.org']]]], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testGetOneAliasByIdOrAddress(): void
|
||||
{
|
||||
$byId = $this->get('get/alias/12', ['script' => [self::answer('mailbox', ['get', 'alias_details', '12'], self::SALES)]]);
|
||||
$this->assertSame(self::SALES, $byId->json());
|
||||
|
||||
$byAddress = $this->get('get/alias/sales@example.org');
|
||||
$this->assertSame([['get', 'alias_details', 'sales@example.org']], $byAddress->callsTo('mailbox'));
|
||||
$this->assertSame('{}', $byAddress->body);
|
||||
}
|
||||
|
||||
public function testGetAllWalksTheDomainsAndAliasDomains(): void
|
||||
{
|
||||
$result = $this->get('get/alias/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], ['example.net']),
|
||||
self::answer('mailbox', ['get', 'aliases', 'example.org'], [12, 13, 14]),
|
||||
self::answer('mailbox', ['get', 'aliases', 'example.com'], []),
|
||||
self::answer('mailbox', ['get', 'aliases', 'example.net'], [20]),
|
||||
self::answer('mailbox', ['get', 'alias_details', 12], self::SALES),
|
||||
self::answer('mailbox', ['get', 'alias_details', 13], self::INFO),
|
||||
self::answer('mailbox', ['get', 'alias_details', 14], false),
|
||||
self::answer('mailbox', ['get', 'alias_details', 20], self::SHOP),
|
||||
]]);
|
||||
|
||||
$this->assertSame([self::SALES, self::INFO, self::SHOP], $result->json());
|
||||
$this->assertSame([
|
||||
['get', 'domains'],
|
||||
['get', 'alias_domains'],
|
||||
['get', 'aliases', 'example.org'],
|
||||
['get', 'alias_details', 12],
|
||||
['get', 'alias_details', 13],
|
||||
['get', 'alias_details', 14],
|
||||
['get', 'aliases', 'example.com'],
|
||||
['get', 'aliases', 'example.net'],
|
||||
['get', 'alias_details', 20],
|
||||
], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testGetAllOfTheNamedDomains(): void
|
||||
{
|
||||
$result = $this->get('get/alias/all/example.org,example.net', ['script' => [
|
||||
self::answer('mailbox', ['get', 'aliases', 'example.org'], [12]),
|
||||
self::answer('mailbox', ['get', 'aliases', 'example.net'], [20]),
|
||||
self::answer('mailbox', ['get', 'alias_details', 12], self::SALES),
|
||||
self::answer('mailbox', ['get', 'alias_details', 20], self::SHOP),
|
||||
]]);
|
||||
|
||||
$this->assertSame([self::SALES, self::SHOP], $result->json());
|
||||
$this->assertSame([
|
||||
['get', 'aliases', 'example.org'],
|
||||
['get', 'alias_details', 12],
|
||||
['get', 'aliases', 'example.net'],
|
||||
['get', 'alias_details', 20],
|
||||
], $result->callsTo('mailbox'));
|
||||
}
|
||||
|
||||
public function testGetAllWithoutAliases(): void
|
||||
{
|
||||
$noDomains = $this->get('get/alias/all', ['script' => [
|
||||
self::answer('mailbox', ['get', 'domains'], []),
|
||||
self::answer('mailbox', ['get', 'alias_domains'], []),
|
||||
]]);
|
||||
$this->assertSame('{}', $noDomains->body);
|
||||
|
||||
$noAliases = $this->get('get/alias/all/example.org', ['script' => [self::answer('mailbox', ['get', 'aliases'], false)]]);
|
||||
$this->assertSame('{}', $noAliases->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Routing;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
|
||||
/** add/edit/delete/get fwdhost */
|
||||
class ForwardingHostRoutesTest extends ApiTestCase
|
||||
{
|
||||
private const HOSTS = [
|
||||
['host' => '192.0.2.25', 'source' => 'relay.example.com', 'keep_spam' => 'yes'],
|
||||
['host' => '2001:db8::25', 'source' => 'relay.example.com', 'keep_spam' => 'yes'],
|
||||
];
|
||||
|
||||
public function testAddPassesTheBody(): void
|
||||
{
|
||||
$result = $this->post('add/fwdhost', ['hostname' => 'relay.example.com', 'filter_spam' => '1']);
|
||||
|
||||
$this->assertSame([['add', ['hostname' => 'relay.example.com', 'filter_spam' => '1']]], $result->callsTo('fwdhost'));
|
||||
}
|
||||
|
||||
public function testEditNamesTheHostsFwdhost(): void
|
||||
{
|
||||
$result = $this->post('edit/fwdhost', ['items' => ['192.0.2.25', '198.51.100.0/24'], 'attr' => ['keep_spam' => '0']]);
|
||||
|
||||
$this->assertSame([['edit', ['fwdhost' => ['192.0.2.25', '198.51.100.0/24'], 'keep_spam' => '0']]], $result->callsTo('fwdhost'));
|
||||
}
|
||||
|
||||
public function testDeleteNamesTheHostsForwardinghost(): void
|
||||
{
|
||||
$result = $this->post('delete/fwdhost', ['192.0.2.25', '2001:db8::25']);
|
||||
|
||||
$this->assertSame([['delete', ['forwardinghost' => ['192.0.2.25', '2001:db8::25']]]], $result->callsTo('fwdhost'));
|
||||
}
|
||||
|
||||
public function testGetAllPassesTheList(): void
|
||||
{
|
||||
$result = $this->get('get/fwdhost/all', ['script' => [self::answer('fwdhost', ['get'], self::HOSTS)]]);
|
||||
|
||||
$this->assertSame(self::HOSTS, $result->json());
|
||||
$this->assertSame([['get']], $result->callsTo('fwdhost'));
|
||||
|
||||
$this->assertSame('{}', $this->get('get/fwdhost/all', ['script' => [self::answer('fwdhost', ['get'], [])]])->body);
|
||||
}
|
||||
|
||||
public function testGetOneHost(): void
|
||||
{
|
||||
$found = $this->get('get/fwdhost/192.0.2.25', ['script' => [self::answer('fwdhost', ['details', '192.0.2.25'], self::HOSTS[0])]]);
|
||||
$this->assertSame(self::HOSTS[0], $found->json());
|
||||
|
||||
$this->assertSame('{}', $this->get('get/fwdhost/192.0.2.99', ['script' => [self::answer('fwdhost', ['details'], [])]])->body);
|
||||
}
|
||||
|
||||
public function testGetOneNetworkIsCutAtTheSlash(): void
|
||||
{
|
||||
$result = $this->get('get/fwdhost/198.51.100.0/24');
|
||||
|
||||
$this->assertSame([['details', '198.51.100.0']], $result->callsTo('fwdhost'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Routing;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/** bcc, recipient_map and tls-policy-map: json_api.php handles the three alike */
|
||||
class MapRoutesTest extends ApiTestCase
|
||||
{
|
||||
public static function maps(): array
|
||||
{
|
||||
return [
|
||||
'bcc' => ['bcc', 'bcc',
|
||||
['local_dest' => 'example.org', 'bcc_dest' => 'archive@example.com', 'type' => 'rcpt', 'active' => '1'],
|
||||
['id' => 3, 'local_dest' => '@example.org', 'bcc_dest' => 'archive@example.com', 'active' => 1, 'type' => 'rcpt', 'domain' => 'example.org']],
|
||||
'recipient_map' => ['recipient_map', 'recipient_map',
|
||||
['recipient_map_old' => 'old@example.org', 'recipient_map_new' => 'new@example.org', 'active' => 1],
|
||||
['id' => 3, 'recipient_map_old' => 'old@example.org', 'recipient_map_new' => 'new@example.org', 'active' => 1]],
|
||||
'tls-policy-map' => ['tls-policy-map', 'tls_policy_maps',
|
||||
['dest' => 'example.com', 'policy' => 'encrypt', 'parameters' => 'protocols=>=TLSv1.2', 'active' => '1'],
|
||||
['id' => 3, 'dest' => 'example.com', 'policy' => 'encrypt', 'parameters' => '', 'active' => 1]],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testAddPassesTheBody(string $category, string $function, array $attr): void
|
||||
{
|
||||
$result = $this->post("add/$category", $attr);
|
||||
|
||||
$this->assertSame([['add', $attr]], $result->callsTo($function));
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testEditMergesItemsAndAttributes(string $category, string $function): void
|
||||
{
|
||||
$list = $this->post("edit/$category", ['items' => [3, '4'], 'attr' => ['active' => '0']]);
|
||||
$this->assertSame([['edit', ['id' => [3, '4'], 'active' => '0']]], $list->callsTo($function));
|
||||
|
||||
$single = $this->post("edit/$category", ['items' => 3, 'attr' => ['active' => '1']]);
|
||||
$this->assertSame([['edit', ['id' => [3], 'active' => '1']]], $single->callsTo($function));
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testDeleteTakesIds(string $category, string $function): void
|
||||
{
|
||||
$result = $this->post("delete/$category", [3, 4]);
|
||||
|
||||
$this->assertSame([['delete', ['id' => [3, 4]]]], $result->callsTo($function));
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testGetAllCollectsTheDetailsOfEveryId(string $category, string $function, array $attr, array $details): void
|
||||
{
|
||||
$result = $this->get("get/$category/all", ['script' => [
|
||||
self::answer($function, ['get'], [3, 4, 5]),
|
||||
self::answer($function, ['details', 3], $details),
|
||||
self::answer($function, ['details', 4], false),
|
||||
self::answer($function, ['details', 5], ['id' => 5] + $details),
|
||||
]]);
|
||||
|
||||
$this->assertSame([$details, ['id' => 5] + $details], $result->json());
|
||||
$this->assertSame([['get'], ['details', 3], ['details', 4], ['details', 5]], $result->callsTo($function));
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testGetAllWithoutEntries(string $category, string $function): void
|
||||
{
|
||||
$result = $this->get("get/$category/all", ['script' => [self::answer($function, ['get'], [])]]);
|
||||
|
||||
$this->assertSame('{}', $result->body);
|
||||
}
|
||||
|
||||
#[DataProvider('maps')]
|
||||
public function testGetOneAddsANullMember(string $category, string $function, array $attr, array $details): void
|
||||
{
|
||||
// json_api.php appends the undefined $details to the answer
|
||||
$found = $this->get("get/$category/3", ['script' => [self::answer($function, ['details', '3'], $details)]]);
|
||||
$this->assertSame($details + [0 => null], $found->json());
|
||||
|
||||
$missing = $this->get("get/$category/9", ['script' => [self::answer($function, ['details', '9'], false)]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Api\Routing;
|
||||
|
||||
use Mailcow\Tests\Api\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/** relayhost (sender-dependent transports) and transport (transport maps): json_api.php handles both alike */
|
||||
class TransportRoutesTest extends ApiTestCase
|
||||
{
|
||||
public static function transports(): array
|
||||
{
|
||||
return [
|
||||
'relayhost' => ['relayhost',
|
||||
['hostname' => '[smtp.example.com]:587', 'username' => 'relay-user', 'password' => 'secret'],
|
||||
['id' => 1, 'hostname' => '[smtp.example.com]:587', 'username' => 'relay-user', 'active' => 1, 'used_by_domains' => 'example.org']],
|
||||
'transport' => ['transport',
|
||||
['destination' => 'example.com, .example.com', 'nexthop' => '[mx.example.com]:25', 'is_mx_based' => '0', 'active' => '1'],
|
||||
['id' => 1, 'is_mx_based' => 0, 'destination' => 'example.com', 'nexthop' => '[mx.example.com]:25', 'active' => 1]],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testAddPassesTheBody(string $category, array $attr): void
|
||||
{
|
||||
$result = $this->post("add/$category", $attr);
|
||||
|
||||
$this->assertSame([['add', $attr]], $result->callsTo($category));
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testEditMergesItemsAndAttributes(string $category): void
|
||||
{
|
||||
$result = $this->post("edit/$category", ['items' => [1, 2], 'attr' => ['active' => '0', 'password' => '']]);
|
||||
|
||||
$this->assertSame([['edit', ['id' => [1, 2], 'active' => '0', 'password' => '']]], $result->callsTo($category));
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testDeleteTakesIds(string $category): void
|
||||
{
|
||||
$result = $this->post("delete/$category", ['1', 2]);
|
||||
|
||||
$this->assertSame([['delete', ['id' => ['1', 2]]]], $result->callsTo($category));
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testGetAllCollectsTheDetailsOfEveryRow(string $category, array $attr, array $details): void
|
||||
{
|
||||
$result = $this->get("get/$category/all", ['script' => [
|
||||
self::answer($category, ['get'], [['id' => 1, 'active' => 1], ['id' => 2, 'active' => 1], ['id' => 3, 'active' => 0]]),
|
||||
self::answer($category, ['details', 1], $details),
|
||||
self::answer($category, ['details', 2], false),
|
||||
self::answer($category, ['details', 3], ['id' => 3] + $details),
|
||||
]]);
|
||||
|
||||
$this->assertSame([$details, ['id' => 3] + $details], $result->json());
|
||||
$this->assertSame([['get'], ['details', 1], ['details', 2], ['details', 3]], $result->callsTo($category));
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testGetAllWithoutEntries(string $category): void
|
||||
{
|
||||
$none = $this->get("get/$category/all", ['script' => [self::answer($category, ['get'], [])]]);
|
||||
$this->assertSame('{}', $none->body);
|
||||
|
||||
$noDetails = $this->get("get/$category/all", ['script' => [
|
||||
self::answer($category, ['get'], [['id' => 1]]),
|
||||
self::answer($category, ['details'], false),
|
||||
]]);
|
||||
$this->assertSame('{}', $noDetails->body);
|
||||
}
|
||||
|
||||
#[DataProvider('transports')]
|
||||
public function testGetOne(string $category, array $attr, array $details): void
|
||||
{
|
||||
$found = $this->get("get/$category/1", ['script' => [self::answer($category, ['details', '1'], $details)]]);
|
||||
$this->assertSame($details, $found->json());
|
||||
|
||||
$missing = $this->get("get/$category/9", ['script' => [self::answer($category, ['details', '9'], false)]]);
|
||||
$this->assertSame('{}', $missing->body);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "project",
|
||||
"license": "GPL-3.0-only",
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"symfony/yaml": "^7.3"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
|
||||
Generated
+235
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9a4f6f2c4beb80cf38c831d11167da8d",
|
||||
"content-hash": "4211ddb7f0a4ed07b766e52d30177798",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
@@ -1721,6 +1721,239 @@
|
||||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3",
|
||||
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"function.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-16T14:15:10+00:00",
|
||||
"published-time": "2026-11-04T11:25:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-ctype": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00",
|
||||
"published-time": "2026-04-26T14:44:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec",
|
||||
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/yaml-lint"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-11-04T08:27:22+00:00",
|
||||
"published-time": "2026-11-04T11:28:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
"version": "1.3.1",
|
||||
@@ -1782,5 +2015,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.2.29"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.11.0"
|
||||
}
|
||||
Reference in new issue
Block a user