489 lines
29 KiB
PHP
489 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/cooldown.php';
|
|
$connection->executeStatement('CREATE TABLE mailgateway_settings (id INTEGER PRIMARY KEY, config_encrypted TEXT NOT NULL)');
|
|
$di = new \Pimple\Container();
|
|
$di['dbal'] = $connection;
|
|
$di['crypt'] = new class extends \Box_Crypt {
|
|
public function encrypt(string $text, ?string $pass = null): string { return parent::encrypt($text, 'isolated-settings-test-key'); }
|
|
public function decrypt(?string $text, ?string $pass = null) { return parent::decrypt($text, 'isolated-settings-test-key'); }
|
|
};
|
|
$desk = new \Box\Mod\Support\Entity\Helpdesk();
|
|
(new ReflectionProperty($desk, 'id'))->setValue($desk, 2);
|
|
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
|
(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 61);
|
|
$support = new class($desk, $ticket) {
|
|
public array $created = [];
|
|
public function __construct(public $desk, public $ticket) {}
|
|
public function getHelpdeskRepository() { return $this; }
|
|
public function find($id) { return $id === 2 ? $this->desk : null; }
|
|
public function findBy(...$args) { throw new RuntimeException('Configured helpdesk was ignored'); }
|
|
public function ticketCreateForGuest($data) { $this->created[] = $data; return 'guest-hash'; }
|
|
public function findOneByHash($hash) { return $this->ticket; }
|
|
};
|
|
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $support);
|
|
$store = new \Box\Mod\Mailgateway\Settings($di);
|
|
if ($store->get() !== \Box\Mod\Mailgateway\Settings::DEFAULTS || $store->getPublic()['password_configured']
|
|
|| $store->get()['host'] !== '' || $store->get()['username'] !== '' || $store->get()['mailbox'] !== '') {
|
|
throw new RuntimeException('Settings defaults incorrect');
|
|
}
|
|
$input = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, [
|
|
'host' => 'mail.example.com', 'username' => 'support@example.com', 'mailbox' => 'support@example.com',
|
|
'enabled' => '0', 'support_helpdesk_id' => '2', 'password' => 'test-only-password',
|
|
]);
|
|
$store->save($input);
|
|
$stored = $connection->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1');
|
|
if (!str_starts_with($stored, 'v2:') || str_contains($stored, 'test-only-password')
|
|
|| str_contains($stored, 'support@example.com') || array_key_exists('password', $store->getPublic())) {
|
|
throw new RuntimeException('Settings stored or exposed in plaintext');
|
|
}
|
|
$input['password'] = '';
|
|
$store->save($input);
|
|
if ($store->get()['password'] !== 'test-only-password' || !$store->getPublic()['password_configured']) {
|
|
throw new RuntimeException('Blank password did not preserve saved credential');
|
|
}
|
|
foreach ([
|
|
['host' => 'https://example.com'], ['port' => '0'], ['port' => '65536'],
|
|
['encryption' => 'none'], ['mailbox' => 'bad-email'], ['enabled' => 'maybe'],
|
|
['inbox_folder' => 'Processed'], ['username' => "bad\r\nuser"],
|
|
['support_helpdesk_id' => '999'], ['password' => ['invalid']],
|
|
['clear_password' => '1', 'password' => 'conflicting-password'],
|
|
] as $changes) {
|
|
try {
|
|
$store->save(array_replace($input, $changes));
|
|
throw new RuntimeException('Invalid settings accepted');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
}
|
|
$service = new \Box\Mod\Mailgateway\Service();
|
|
$service->setDi($di);
|
|
$result = $service->import(['sender_email' => 'new-guest@example.com', 'subject' => 'Configured helpdesk', 'content' => 'Test body']);
|
|
if ($result['support_helpdesk_id'] !== 2 || $support->created[0]['support_helpdesk_id'] !== 2) {
|
|
throw new RuntimeException('Selected helpdesk not used for import');
|
|
}
|
|
$input['enabled'] = '0';
|
|
$input['clear_password'] = '1';
|
|
$store->save($input);
|
|
if ($store->getPublic()['password_configured']) { throw new RuntimeException('Password not cleared'); }
|
|
try {
|
|
$store->save(array_replace($input, ['enabled' => '1', 'clear_password' => '0']));
|
|
throw new RuntimeException('Enabled imports without a password');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
|
|
$input['clear_password'] = '0';
|
|
$input['password'] = 'test-only-password';
|
|
$store->save($input);
|
|
$source = file_get_contents(dirname(__DIR__) . '/templates/admin/mod_mailgateway_settings.html.twig');
|
|
$twig = new \Twig\Environment(new \Twig\Loader\ArrayLoader([
|
|
'settings' => $source,
|
|
'layout_default.html.twig' => '{% block content %}{% endblock %}',
|
|
'layout_blank.html.twig' => '{% block content %}{% endblock %}',
|
|
]), ['autoescape' => 'html', 'strict_variables' => true]);
|
|
$twig->addFilter(new \Twig\TwigFilter('trans', fn ($text) => $text));
|
|
$twig->addFilter(new \Twig\TwigFilter('api_url', fn ($path, $role = 'admin') => '/api/admin/' . $path));
|
|
$twig->addFunction(new \Twig\TwigFunction('fb_api_form', fn ($options) => 'class="api-form"', ['is_safe' => ['html']]));
|
|
$admin = new class($store) {
|
|
public function __construct(public $store) {}
|
|
public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'helpdesks' => [2 => 'Helpdesk <script>']]; }
|
|
};
|
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
|
if (str_contains($html, 'test-only-password') || str_contains($html, 'Helpdesk <script>')
|
|
|| !str_contains($html, 'Helpdesk <script>') || !str_contains($html, '/api/admin/mailgateway/settings_save')) {
|
|
throw new RuntimeException('Settings page leaked credentials or failed to escape values');
|
|
}
|
|
$api = new \Box\Mod\Mailgateway\Api\Admin();
|
|
foreach (['settings_get', 'settings_save', 'install_dependencies', 'setup', 'mailbox_delete', 'notifications_setup'] as $method) {
|
|
try {
|
|
$api->$method([]);
|
|
throw new RuntimeException('Unauthenticated settings access accepted');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
}
|
|
echo "Passed settings validation, actual encryption, secret redaction, password retention/clearing, selected helpdesk routing, Twig rendering/escaping, and unauthenticated rejection.\n";
|
|
|
|
// Existing single-mailbox ciphertext migrates without losing its credentials.
|
|
$legacy = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, ['host' => 'legacy.example.com', 'username' => 'legacy@example.com', 'mailbox' => 'legacy@example.com', 'password' => 'legacy-password', 'support_helpdesk_id' => 2]);
|
|
$connection->update('mailgateway_settings', ['config_encrypted' => $di['crypt']->encrypt(json_encode($legacy))], ['id' => 1]);
|
|
if ($store->get() !== $legacy) { throw new RuntimeException('Legacy mailbox values or password changed'); }
|
|
$secondDesk = new \Box\Mod\Support\Entity\Helpdesk();
|
|
(new ReflectionProperty($secondDesk, 'id'))->setValue($secondDesk, 3);
|
|
$multiSupport = new class($support, $secondDesk) {
|
|
public function __construct(public $original, public $secondDesk) {}
|
|
public function getHelpdeskRepository() { return $this; }
|
|
public function find($id) { return $id === 3 ? $this->secondDesk : $this->original->find($id); }
|
|
public function ticketCreateForGuest($data) { return $this->original->ticketCreateForGuest($data); }
|
|
public function findOneByHash($hash) { return $this->original->findOneByHash($hash); }
|
|
};
|
|
unset($di['mod_service']);
|
|
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $multiSupport);
|
|
$new = array_replace($input, ['mailbox_id' => 'new', 'mailbox' => 'billing@example.com', 'username' => 'billing@example.com', 'support_helpdesk_id' => 3, 'password' => 'second-password']);
|
|
$store->save($new);
|
|
$mailboxes = $store->getAllPublic();
|
|
$newId = array_key_last($mailboxes);
|
|
if (count($mailboxes) !== 2 || $store->get()['password'] !== 'legacy-password' || $store->get($newId)['password'] !== 'second-password') {
|
|
throw new RuntimeException('Adding mailbox overwrote credentials');
|
|
}
|
|
$new['mailbox_id'] = $newId;
|
|
$new['password'] = '';
|
|
$store->save($new);
|
|
if ($store->get($newId)['password'] !== 'second-password') { throw new RuntimeException('Wrong mailbox password retained'); }
|
|
try {
|
|
$store->save(array_replace($new, ['mailbox_id' => 'new']));
|
|
throw new RuntimeException('Duplicate mailbox accepted');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
$result = $service->import(['mailbox' => 'BILLING@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Second helpdesk', 'content' => 'Body']);
|
|
if ($result['support_helpdesk_id'] !== 3) { throw new RuntimeException('Second mailbox routing failed'); }
|
|
try {
|
|
$service->import(['mailbox' => 'unknown@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Unknown mailbox', 'content' => 'Body']);
|
|
throw new RuntimeException('Unknown mailbox routed to default');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
$admin = new class($store) {
|
|
public function __construct(public $store) {}
|
|
public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(), 'helpdesks' => [2 => 'General', 3 => 'Billing']]; }
|
|
};
|
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
|
if (!str_contains($html, 'billing@example.com') || substr_count($html, '>Test Connection and Create Folders</button>') !== 2 || str_contains($html, 'second-password') || str_contains($html, 'legacy-password')) {
|
|
throw new RuntimeException('Multiple mailbox rendering failed');
|
|
}
|
|
require dirname(__DIR__) . '/ImapConnectionTest.php';
|
|
trait ImapTestCapabilities
|
|
{
|
|
public bool $supportsMove = true;
|
|
public bool $failCapabilities = false;
|
|
public function getConnection() {
|
|
return new class($this) {
|
|
public function __construct(public $client) {}
|
|
public function getCapabilities() {
|
|
if ($this->client->failCapabilities) { throw new RuntimeException('SECRET capabilities'); }
|
|
return new class($this->client->supportsMove) {
|
|
public function __construct(public bool $move) {}
|
|
public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; }
|
|
};
|
|
}
|
|
};
|
|
}
|
|
}
|
|
$client = new class {
|
|
use ImapTestCapabilities;
|
|
public bool $disconnected = false;
|
|
public bool $fail = false;
|
|
public function connect() { if ($this->fail) { throw new RuntimeException('secret-second-password'); } }
|
|
public function getFolders($hierarchical) {
|
|
if ($hierarchical !== false) { throw new RuntimeException('Expected flat folder listing'); }
|
|
return [(object) ['path' => 'inbox'], (object) ['path' => 'Processed']];
|
|
}
|
|
public function disconnect() { $this->disconnected = true; }
|
|
};
|
|
$test = new \Box\Mod\Mailgateway\ImapConnectionTest(function ($config) use ($client) {
|
|
if (!$config['validate_cert'] || $config['password'] !== 'second-password' || $config['username'] !== 'billing@example.com') { throw new RuntimeException('Wrong IMAP config'); }
|
|
return $client;
|
|
});
|
|
$check = $test->test($store->get($newId));
|
|
if (!$check['connected'] || !$check['folders'][0]['exists'] || $check['folders'][2]['exists'] || !$client->disconnected) { throw new RuntimeException('IMAP folder test failed'); }
|
|
$client->fail = true;
|
|
$client->disconnected = false;
|
|
$failedCheck = $test->test($store->get($newId));
|
|
if ($failedCheck['success'] || $failedCheck['connected'] || !$client->disconnected
|
|
|| str_contains(json_encode($failedCheck), 'secret')) {
|
|
throw new RuntimeException('IMAP failure leaked secrets or skipped cleanup');
|
|
}
|
|
try { $api->test_connection([]); throw new RuntimeException('Unauthenticated IMAP test accepted'); } catch (\FOSSBilling\InformationException) {}
|
|
echo "Passed legacy migration, multiple mailbox credential isolation, duplicate rejection, per-mailbox helpdesk routing, multi-mailbox Twig rendering, read-only IMAP checks, disconnect cleanup, and secret-safe failures.\n";
|
|
|
|
// Render all dependency states using the actual settings-page template.
|
|
foreach ([
|
|
[['installed' => true, 'available' => true], 'Mail Gateway dependencies are installed.', true],
|
|
[['installed' => false, 'available' => true], 'Install dependencies', true],
|
|
[['installed' => false, 'available' => false], 'Automatic installation is unavailable', false],
|
|
] as [$dependencies, $expected, $button]) {
|
|
$admin = new class($store, $dependencies) {
|
|
public function __construct(public $store, public $dependencies) {}
|
|
public function mailgateway_settings_get($data) {
|
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
|
'helpdesks' => [2 => 'General', 3 => 'Billing'], 'dependencies' => $this->dependencies];
|
|
}
|
|
};
|
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
|
if (!str_contains($html, $expected) || str_contains($html, '/api/admin/mailgateway/install_dependencies') !== $button) {
|
|
throw new RuntimeException('Dependency installation controls rendered incorrectly');
|
|
}
|
|
preg_match('/<input[^>]*type="checkbox"[^>]*name="enabled"[^>]*>/', $html, $switch);
|
|
if ($switch === [] || !str_contains($switch[0], 'disabled')) {
|
|
throw new RuntimeException('Import switch is not gated by installed dependencies');
|
|
}
|
|
if ($dependencies['installed'] && strpos($html, 'Mail Gateway dependencies are installed.') < strpos($html, '>Advanced</summary>')) {
|
|
throw new RuntimeException('Installed dependency status is outside Advanced');
|
|
}
|
|
}
|
|
require_once dirname(__DIR__) . '/DependencyInstaller.php';
|
|
$runner = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
|
public function executeForTest(array $command): array { return $this->run($command); }
|
|
};
|
|
$result = $runner->executeForTest([PHP_BINARY, '-r', 'fwrite(STDOUT, str_repeat("secret", 30000)); fwrite(STDERR, str_repeat("secret", 30000)); exit(7);']);
|
|
if ($result !== ['exit' => 7, 'timeout' => false]) {
|
|
throw new RuntimeException('Dependency runner failed to drain pipes, preserve exit code or suppress sensitive output');
|
|
}
|
|
echo "Passed dependency controls, installer authentication, subprocess pipe draining, exit-code handling, and output redaction without running Composer.\n";
|
|
|
|
$reinstaller = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
|
public array $commands = [];
|
|
protected function run(array $command): array {
|
|
$this->commands[] = $command;
|
|
return ['exit' => 0, 'timeout' => false];
|
|
}
|
|
};
|
|
$reinstaller->install();
|
|
if ($reinstaller->commands !== []) { throw new RuntimeException('Normal install did not skip existing dependencies'); }
|
|
$reinstaller->install(true);
|
|
if (count($reinstaller->commands) !== 1 || !in_array('install', $reinstaller->commands[0], true)
|
|
|| !in_array('--no-scripts', $reinstaller->commands[0], true) || !in_array('--no-plugins', $reinstaller->commands[0], true)) {
|
|
throw new RuntimeException('Forced Composer rerun did not reach the installer');
|
|
}
|
|
if (!str_contains($html, '>Advanced</summary>') || !str_contains($html, '/api/admin/mailgateway/setup')) {
|
|
throw new RuntimeException('Advanced table setup control missing');
|
|
}
|
|
$missingDb = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
|
$missingDi = new \Pimple\Container();
|
|
$missingDi['dbal'] = $missingDb;
|
|
if ((new \Box\Mod\Mailgateway\Settings($missingDi))->get()['host'] !== '') {
|
|
throw new RuntimeException('Missing settings table prevented access to setup page');
|
|
}
|
|
echo "Passed Advanced rendering, forced Composer rerun, and settings recovery when module tables are missing.\n";
|
|
|
|
$di['mailgateway_dependency_installer'] = new class {
|
|
public function status() { return ['installed' => false]; }
|
|
};
|
|
try {
|
|
$store->save(array_replace($new, ['enabled' => '1']));
|
|
throw new RuntimeException('Importing enabled without installed dependencies');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
$store->save(array_replace($new, ['enabled' => '0']));
|
|
unset($di['mailgateway_dependency_installer']);
|
|
|
|
$folderClient = new class {
|
|
use ImapTestCapabilities;
|
|
public array $paths = ['Processed'];
|
|
public array $created = [];
|
|
public bool $disconnected = false;
|
|
public bool $failCreate = false;
|
|
public function connect() {}
|
|
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], $this->paths); }
|
|
public function createFolder($path, $expunge) {
|
|
if ($expunge !== false || $path === 'INBOX') { throw new RuntimeException('Unsafe folder creation'); }
|
|
if ($this->failCreate) { throw new RuntimeException('SECRET folder failure'); }
|
|
$this->created[] = $path;
|
|
$this->paths[] = $path;
|
|
}
|
|
public function disconnect() { $this->disconnected = true; }
|
|
};
|
|
$folderTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $folderClient);
|
|
$check = $folderTest->test($store->get($newId), true);
|
|
if ($folderClient->created !== ['Ignored', 'Failed'] || $check['folders'][0]['exists']
|
|
|| !$check['folders'][2]['created'] || !$check['folders'][3]['exists'] || !$folderClient->disconnected) {
|
|
throw new RuntimeException('Destination folder creation or missing-inbox handling failed');
|
|
}
|
|
$folderTest->test($store->get($newId), true);
|
|
if ($folderClient->created !== ['Ignored', 'Failed']) { throw new RuntimeException('Existing folders were recreated'); }
|
|
$folderClient->paths = [];
|
|
$folderClient->failCreate = true;
|
|
$folderClient->disconnected = false;
|
|
$folderFailure = $folderTest->test($store->get($newId), true);
|
|
if ($folderFailure['success'] || !$folderFailure['connected'] || !$folderClient->disconnected
|
|
|| str_contains(json_encode($folderFailure), 'SECRET')
|
|
|| !str_contains($folderFailure['folders'][1]['error'], 'Folder creation failed')) {
|
|
throw new RuntimeException('Folder failure did not distinguish login from folder creation');
|
|
}
|
|
echo "Passed dependency status placement, disabled import switches, server-side enablement guard, safe destination folder creation, repeated setup, and failure cleanup.\n";
|
|
|
|
// Readiness is per mailbox, persisted, and tied to the tested configuration.
|
|
$readyClient = new class {
|
|
use ImapTestCapabilities;
|
|
public function connect() {}
|
|
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], ['INBOX', 'Processed', 'Ignored', 'Failed']); }
|
|
public function disconnect() {}
|
|
};
|
|
$readyTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $readyClient);
|
|
$readySettings = $store->get($newId);
|
|
$readyResult = $readyTest->test($readySettings, true);
|
|
if (!$readyResult['success']) { throw new RuntimeException('Valid mailbox did not pass readiness'); }
|
|
$store->recordTest($newId, $readySettings, $readyResult);
|
|
if (!$store->getAllPublic()[$newId]['test_passed'] || $store->getPublic()['test_passed']) {
|
|
throw new RuntimeException('Readiness did not remain isolated to tested mailbox');
|
|
}
|
|
$store->save(array_replace($new, ['enabled' => '1']));
|
|
if (!$store->get($newId)['enabled']) { throw new RuntimeException('Tested mailbox could not be enabled'); }
|
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
|
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']) {
|
|
throw new RuntimeException('Connection changes did not revoke readiness and disable imports');
|
|
}
|
|
try { $store->recordTest($newId, $readySettings, $readyResult); throw new RuntimeException('Stale test accepted'); }
|
|
catch (\FOSSBilling\InformationException) {}
|
|
$readySettings = $store->get($newId);
|
|
$store->recordTest($newId, $readySettings, $readyResult);
|
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
|
$store->recordTest($newId, $store->get($newId), $folderFailure);
|
|
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']
|
|
|| $store->getAllPublic()[$newId]['test_result'] !== $folderFailure) {
|
|
throw new RuntimeException('Failed retest did not disable importing and persist diagnostics');
|
|
}
|
|
try {
|
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com', 'tested_config_hash' => 'fake', 'test_passed' => true]));
|
|
throw new RuntimeException('Unverified enablement or spoofed readiness accepted');
|
|
} catch (\FOSSBilling\InformationException) {}
|
|
$sourceSettings = $store->get($newId);
|
|
$di['mailgateway_connection_test'] = $readyTest;
|
|
$service->testConnection(['mailbox_id' => $newId]);
|
|
if (!(new \Box\Mod\Mailgateway\Settings($di))->getAllPublic()[$newId]['test_passed']) {
|
|
throw new RuntimeException('Settings-page connection test did not persist readiness');
|
|
}
|
|
unset($di['mailgateway_connection_test']);
|
|
$di['mailgateway_connection_test'] = new class($folderFailure) {
|
|
public function __construct(public $result) {}
|
|
public function test($settings, $create) { return $this->result; }
|
|
};
|
|
$service->testConnection(['mailbox_id' => $newId]);
|
|
$admin = new class($store) {
|
|
public function __construct(public $store) {}
|
|
public function mailgateway_settings_get($data) {
|
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
|
'dependencies' => ['installed' => true, 'available' => true], 'helpdesks' => [2 => 'General', 3 => 'Billing']];
|
|
}
|
|
};
|
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
|
if (!str_contains($html, 'Folder creation failed') || !str_contains($html, 'Importing remains disabled')
|
|
|| !str_contains($html, 'title="Save this mailbox, then run Test Connection')) {
|
|
throw new RuntimeException('Persisted failure or hover explanation missing from page');
|
|
}
|
|
echo "Passed per-mailbox readiness, server-side enablement enforcement, configuration invalidation, stale-test rejection, failed-retest disabling, persisted test results, and explanatory UI.\n";
|
|
|
|
$keptMailbox = $store->get('default');
|
|
$store->delete($newId);
|
|
if (count($store->getAll()) !== 1 || $store->get('default') !== $keptMailbox) {
|
|
throw new RuntimeException('Deleting a mailbox modified another mailbox');
|
|
}
|
|
try { $store->delete('unknown'); throw new RuntimeException('Unknown mailbox deletion accepted'); }
|
|
catch (\FOSSBilling\InformationException) {}
|
|
$store->delete('default');
|
|
if ($store->getAll() !== [] || $store->getPublic()['mailbox'] !== '') {
|
|
throw new RuntimeException('Deleting last mailbox failed');
|
|
}
|
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
|
if (str_contains($html, '>Delete mailbox</button>') || !str_contains($html, 'Add mailbox')) {
|
|
throw new RuntimeException('Empty mailbox list rendered a phantom mailbox');
|
|
}
|
|
$new['mailbox_id'] = 'new';
|
|
$new['enabled'] = '0';
|
|
$new['password'] = 'replacement-password';
|
|
$store->save($new);
|
|
if (count($store->getAll()) !== 1 || $store->get()['password'] !== 'replacement-password') {
|
|
throw new RuntimeException('Adding mailbox after deleting last mailbox failed');
|
|
}
|
|
echo "Passed mailbox deletion, remaining credential preservation, unknown-ID rejection, empty-page rendering, and adding a replacement mailbox.\n";
|
|
|
|
$readyClient->supportsMove = false;
|
|
$noMove = $readyTest->test($sourceSettings, true);
|
|
if (!$noMove['connected'] || $noMove['success'] || $noMove['move_supported'] !== false
|
|
|| !str_contains(implode(' ', $noMove['errors']), 'does not support native MOVE')) {
|
|
throw new RuntimeException('Successful login without MOVE was not rejected clearly');
|
|
}
|
|
$readyClient->supportsMove = true;
|
|
$readyClient->failCapabilities = true;
|
|
$capabilityFailure = $readyTest->test($sourceSettings, true);
|
|
if (!$capabilityFailure['connected'] || $capabilityFailure['success']
|
|
|| !str_contains(implode(' ', $capabilityFailure['errors']), 'checking server MOVE support failed')
|
|
|| str_contains(json_encode($capabilityFailure), 'SECRET')) {
|
|
throw new RuntimeException('MOVE check failure did not preserve login result or hide secrets');
|
|
}
|
|
$readyClient->failCapabilities = false;
|
|
echo "Passed native MOVE requirement, distinct capability failures, and secret-safe shared connection diagnostics.\n";
|
|
|
|
$notificationDi = new \Pimple\Container();
|
|
$notificationEmail = new class {
|
|
public array $templates = [];
|
|
public function getTemplateRepository() { return $this; }
|
|
public function findOneByActionCode($code) { return $this->templates[$code] ?? null; }
|
|
public function templateToApiArray($template) {
|
|
return ['enabled' => $template->isEnabled(), 'subject' => $template->getSubject()];
|
|
}
|
|
public int $updates = 0;
|
|
public function updateTemplate($template, $enabled = null, $category = null, $subject = null): bool {
|
|
if ($enabled !== null) $template->setEnabled($enabled);
|
|
if ($subject !== null) $template->setSubject($subject);
|
|
++$this->updates;
|
|
return true;
|
|
}
|
|
public function templateCreate($code, $subject, $content, $enabled, $category) {
|
|
return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
|
->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled);
|
|
}
|
|
};
|
|
foreach (['mod_support_ticket_open', 'mod_support_ticket_staff_reply',
|
|
'mod_support_ticket_staff_open', 'mod_support_ticket_staff_close'] as $code) {
|
|
$notificationEmail->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
|
->setSubject('[Company] [Ticket #{{ ticket.id }}] {{ ticket.subject }}');
|
|
}
|
|
$notificationDi['mod_service'] = $notificationDi->protect(fn ($module) => $notificationEmail);
|
|
$notificationStore = new class {
|
|
public int $saves = 0;
|
|
public function save($data): bool { ++$this->saves; return true; }
|
|
};
|
|
$notificationDi['mailgateway_settings'] = $notificationStore;
|
|
$notificationService = new \Box\Mod\Mailgateway\Service();
|
|
$notificationService->setDi($notificationDi);
|
|
if (!$notificationService->getNotificationStatus()['ready']) {
|
|
throw new RuntimeException('Valid ticket notification templates were rejected');
|
|
}
|
|
$notificationService->saveSettings(['enabled' => '1']);
|
|
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(false);
|
|
$notificationService->saveSettings(['enabled' => '1']);
|
|
if (!$notificationEmail->templates['mod_support_ticket_open']->isEnabled() || $notificationStore->saves !== 2) {
|
|
throw new RuntimeException('Acknowledgement was not enabled automatically');
|
|
}
|
|
$notificationService->saveSettings(['enabled' => '0']);
|
|
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(true);
|
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setSubject('Reply without marker');
|
|
if ($notificationService->getNotificationStatus()['ready']) {
|
|
throw new RuntimeException('Missing staff reply ticket marker accepted');
|
|
}
|
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(false);
|
|
if (!$notificationService->getNotificationStatus()['ready']) {
|
|
throw new RuntimeException('Optional disabled staff notification rejected');
|
|
}
|
|
unset($notificationEmail->templates['mod_support_ticket_open']);
|
|
if ($notificationService->getNotificationStatus()['ready']) {
|
|
throw new RuntimeException('Missing acknowledgement template accepted');
|
|
}
|
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(true)->setContent('Keep this body');
|
|
$notificationService->saveSettings(['enabled' => '1']);
|
|
if (!$notificationService->getNotificationStatus()['ready']
|
|
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getSubject() !== '[Ticket #{{ ticket.id }}] Reply without marker'
|
|
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getContent() !== 'Keep this body') {
|
|
throw new RuntimeException('Automatic notification repair failed to preserve wording');
|
|
}
|
|
$updates = $notificationEmail->updates;
|
|
$notificationService->configureNotifications();
|
|
if ($notificationEmail->updates !== $updates) throw new RuntimeException('Repeated notification repair changed valid templates');
|
|
echo "Passed notification readiness, automatic repair, preserved wording, and repeatability.\n";
|
|
|
|
$notificationAdmin = new class($store) {
|
|
public bool $ready = true;
|
|
public function __construct(public $store) {}
|
|
public function mailgateway_settings_get($data) {
|
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
|
'helpdesks' => [2 => 'General'], 'dependencies' => ['installed' => true, 'available' => false],
|
|
'notifications' => ['ready' => $this->ready, 'errors' => $this->ready ? [] : ['Template <needs repair>']]];
|
|
}
|
|
};
|
|
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
|
if (!preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
|
|| !str_contains($notificationHtml, 'mailgateway/notifications_setup')) {
|
|
throw new RuntimeException('Ready notification banner was not hidden or Advanced setup was missing');
|
|
}
|
|
$notificationAdmin->ready = false;
|
|
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
|
if (preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
|
|| !str_contains($notificationHtml, 'Template <needs repair>')) {
|
|
throw new RuntimeException('Notification warnings were hidden or not escaped');
|
|
}
|
|
echo "Passed notification banner visibility, Advanced setup rendering, and warning escaping.\n";
|