[Web] API: complete the OpenAPI docs, add route tests for json_api.php

This commit is contained in:
FreddleSpl0it committed 2026-11-10 20:04:05 +01:00
1 parent 9825c8d290
commit 2418da031f
47 files changed
+18848 -7491

No files matched your search

+14626 -7488
View File
File diff suppressed because it is too large. Load diff
+82
View File
@@ -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());
}
}
+76
View File
@@ -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');
}
}
+82
View File
@@ -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'));
}
}
+120
View File
@@ -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');
}
}
}
+43
View File
@@ -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'));
}
}
+150
View File
@@ -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);
}
}
+71
View File
@@ -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']]]);
}
}
+62
View File
@@ -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'));
}
}
+115
View File
@@ -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]]]);
}
}
+152
View File
@@ -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]);
}
}
+34
View File
@@ -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');
}
}
+70
View File
@@ -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());
}
}
+78
View File
@@ -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());
}
}
+118
View File
@@ -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');
}
}
+266
View File
@@ -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());
}
}
+194
View File
@@ -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());
}
}
+155
View File
@@ -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';
}
}
+131
View File
@@ -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());
}
}
+115
View File
@@ -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;
}
}
+116
View File
@@ -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'));
}
}
+86
View File
@@ -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);
}
}
+2 -1
View File
@@ -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": {
+235 -2
View File
@@ -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"
}