fix: harden backend synchronization
This commit is contained in:
@@ -50,6 +50,23 @@ function attach_rule_scope(AppContext $ctx, array $rule): array
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $rule */
|
||||
function enforce_rule_backend_constraints(AppContext $ctx, ?string $currentId, array $rule): void
|
||||
{
|
||||
if ($ctx->settings->backendMode() !== 'da' || empty($rule['enabled'])) {
|
||||
return;
|
||||
}
|
||||
foreach ($ctx->rules->all($ctx->daUser->username()) as $existing) {
|
||||
if (empty($existing['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
if ($currentId !== null && (string)($existing['id'] ?? '') === $currentId) {
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException($ctx->t('DirectAdmin backend supports only one enabled global rule.'));
|
||||
}
|
||||
}
|
||||
|
||||
function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value): string
|
||||
{
|
||||
$mode = $ctx->settings->scheduleMode();
|
||||
@@ -79,15 +96,24 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st
|
||||
'</select>';
|
||||
}
|
||||
return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar" data-calendar-picker data-schedule-mode="' . AppContext::e($mode) . '" data-calendar-name="' . AppContext::e($name) . '" data-visible-month="' . AppContext::e($visibleMonth) . '" data-selected-date="' . AppContext::e($selectedDate) . '"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn" data-calendar-prev>‹</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>›</button></div>' .
|
||||
'<table class="br-calendar-grid"><thead><tr><th>Pn</th><th>Wt</th><th>Śr</th><th>Cz</th><th>Pt</th><th>Sb</th><th>Nd</th></tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' .
|
||||
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">Wybrano: ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
|
||||
'<table class="br-calendar-grid"><thead><tr>' . render_weekday_headers($ctx) . '</tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' .
|
||||
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
|
||||
'<div class="br-restore-calendar"><h4>' . AppContext::e($ctx->t('Time')) . '</h4>' . $timeControl . '</div></div>' .
|
||||
'<input type="hidden" id="' . AppContext::e($name) . '" name="' . AppContext::e($name) . '" value="' . AppContext::e($value) . '" data-canonical-value="' . AppContext::e($name) . '"></div>';
|
||||
}
|
||||
|
||||
function render_preview_box(array $rule): string
|
||||
function render_preview_box(AppContext $ctx, array $rule): string
|
||||
{
|
||||
return '<div class="preview-box"><div><strong>Temat:</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
|
||||
return '<div class="preview-box"><div><strong>' . AppContext::e($ctx->t('Subject')) . ':</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
|
||||
}
|
||||
|
||||
function render_weekday_headers(AppContext $ctx): string
|
||||
{
|
||||
$html = '';
|
||||
foreach (['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day) {
|
||||
$html .= '<th>' . AppContext::e($ctx->t($day)) . '</th>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string
|
||||
|
||||
@@ -8,8 +8,14 @@ return static function (AppContext $ctx): void {
|
||||
$ctx->requireCsrf('create');
|
||||
$input = normalize_rule_post($_POST);
|
||||
$rule = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
|
||||
$ctx->rules->create($ctx->daUser->username(), $rule);
|
||||
enforce_rule_backend_constraints($ctx, null, $rule);
|
||||
$created = $ctx->rules->create($ctx->daUser->username(), $rule);
|
||||
try {
|
||||
$ctx->syncActiveBackend();
|
||||
} catch (Throwable $syncError) {
|
||||
$ctx->rules->delete($ctx->daUser->username(), (string)$created['id']);
|
||||
throw $syncError;
|
||||
}
|
||||
Http::redirect($ctx->url('index.html', ['status' => 'created']));
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
@@ -20,7 +20,7 @@ return static function (AppContext $ctx): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
|
||||
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($ctx, $rule) . '</div>' .
|
||||
'<div class="panel"><h3>' . AppContext::e($ctx->t('Confirm deletion')) . '</h3><div class="alert alert-err">' . AppContext::e($ctx->t('Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.')) . '</div>' .
|
||||
'<form method="post" action="' . AppContext::e($ctx->url('delete.html')) . '">' . $ctx->csrfField('delete:' . $id) . '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' .
|
||||
'<div class="actions"><button class="danger-fill" type="submit">' . AppContext::e($ctx->t('Delete autoresponder')) . '</button><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Do not delete')) . '</a></div></form></div></div>';
|
||||
|
||||
@@ -8,7 +8,7 @@ return static function (AppContext $ctx): void {
|
||||
$ctx->render('Preview autoresponder', '<div class="alert alert-err">' . AppContext::e($ctx->t('Rule not found')) . '</div>');
|
||||
return;
|
||||
}
|
||||
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
|
||||
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($ctx, $rule) . '</div>' .
|
||||
'<div class="panel"><h3>' . AppContext::e($ctx->t('Rule details')) . '</h3><div class="summary-card"><div class="kv"><strong>' . AppContext::e($ctx->t('Status')) . '</strong><span>' . AppContext::e($ctx->t(!empty($rule['enabled']) ? 'Enabled' : 'Disabled')) . '</span></div>' .
|
||||
'<div class="kv"><strong>' . AppContext::e($ctx->t('Sending period')) . '</strong><span>' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts']) . ' - ' . date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></div></div>' .
|
||||
'<div class="actions"><a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a><a class="btn danger" href="' . AppContext::e($ctx->url('delete.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Delete')) . '</a><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Back')) . '</a></div></div></div>';
|
||||
|
||||
@@ -9,7 +9,9 @@ return static function (AppContext $ctx): void {
|
||||
if ($rule === null) {
|
||||
throw new RuntimeException($ctx->t('Rule not found'));
|
||||
}
|
||||
$ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]);
|
||||
$next = ['enabled' => empty($rule['enabled'])];
|
||||
enforce_rule_backend_constraints($ctx, $id, $next);
|
||||
$ctx->rules->update($ctx->daUser->username(), $id, $next);
|
||||
$ctx->syncActiveBackend();
|
||||
Http::redirect($ctx->url('index.html', ['status' => 'toggled']));
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ return static function (AppContext $ctx): void {
|
||||
$ctx->requireCsrf('update:' . $id);
|
||||
$input = normalize_rule_post($_POST);
|
||||
$patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
|
||||
enforce_rule_backend_constraints($ctx, $id, $patch);
|
||||
$ctx->rules->update($ctx->daUser->username(), $id, $patch);
|
||||
$ctx->syncActiveBackend();
|
||||
Http::redirect($ctx->url('index.html', ['status' => 'updated']));
|
||||
|
||||
@@ -101,7 +101,7 @@ final class AppContext
|
||||
echo '<header><h1>' . self::e($this->t($title)) . '</h1><p>' . self::e($this->t('Manage automatic replies for account mailboxes')) . '</p></header>';
|
||||
echo '<div class="top-actions"><a class="btn amber' . ($this->action() === 'index' ? ' active' : '') . '" href="' . self::e($this->url('index.html')) . '">' . self::e($this->t('AUTORESPONDERS')) . '</a>';
|
||||
echo '<a class="btn amber' . ($this->action() === 'create' ? ' active' : '') . '" href="' . self::e($this->url('create.html')) . '">' . self::e($this->t('ADD AUTORESPONDER')) . '</a></div>';
|
||||
echo '<section class="main-section">' . $alerts . $this->lang->translateHtml($body) . '</section></div>';
|
||||
echo '<section class="main-section">' . $alerts . $body . '</section></div>';
|
||||
echo '<script>' . $this->scripts() . '</script></body></html>';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ class DirectAdminVacationApi
|
||||
{
|
||||
/** @var callable|null */
|
||||
private $request;
|
||||
private string $daBinary = '/usr/local/bin/da';
|
||||
|
||||
public function __construct(private string $daBinary = '/usr/local/bin/da', ?callable $request = null)
|
||||
public function __construct(string $daBinary = '/usr/local/bin/da', ?callable $request = null)
|
||||
{
|
||||
$this->daBinary = $daBinary;
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
@@ -47,6 +49,37 @@ class DirectAdminVacationApi
|
||||
$this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', $this->buildSavePayload($email, $rule, $exists));
|
||||
}
|
||||
|
||||
public function vacationExists(string $owner, string $email): bool
|
||||
{
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException('Invalid mailbox email');
|
||||
}
|
||||
[$local, $domain] = explode('@', strtolower($email), 2);
|
||||
return in_array($local, $this->listVacations($owner, $domain), true);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function listVacations(string $owner, string $domain): array
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9.-]+$/', $domain)) {
|
||||
throw new InvalidArgumentException('Invalid domain');
|
||||
}
|
||||
$response = $this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', ['domain' => strtolower($domain)]);
|
||||
$users = [];
|
||||
foreach (explode('&', trim($response)) as $pair) {
|
||||
if ($pair === '' || !str_contains($pair, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key] = explode('=', $pair, 2);
|
||||
$local = strtolower(urldecode($key));
|
||||
if (preg_match('/^[A-Za-z0-9._+-]+$/', $local)) {
|
||||
$users[] = $local;
|
||||
}
|
||||
}
|
||||
sort($users);
|
||||
return array_values(array_unique($users));
|
||||
}
|
||||
|
||||
public function deleteVacation(string $owner, string $email): void
|
||||
{
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
@@ -70,7 +103,7 @@ class DirectAdminVacationApi
|
||||
}
|
||||
|
||||
/** @param array<string,string> $payload */
|
||||
private function requestAsUser(string $owner, string $endpoint, array $payload): void
|
||||
private function requestAsUser(string $owner, string $endpoint, array $payload): string
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) {
|
||||
throw new InvalidArgumentException('Invalid DirectAdmin owner');
|
||||
@@ -80,7 +113,7 @@ class DirectAdminVacationApi
|
||||
if ($result === false) {
|
||||
throw new RuntimeException('DirectAdmin API request failed');
|
||||
}
|
||||
return;
|
||||
return (string)$result;
|
||||
}
|
||||
$baseUrl = $this->apiUrl($owner);
|
||||
$url = rtrim($baseUrl, '/') . '/' . $endpoint;
|
||||
@@ -90,7 +123,7 @@ class DirectAdminVacationApi
|
||||
$args[] = $key . '=' . $value;
|
||||
}
|
||||
$args[] = $url;
|
||||
$this->run($args, '');
|
||||
return $this->run($args, '');
|
||||
}
|
||||
|
||||
private function apiUrl(string $owner): string
|
||||
|
||||
@@ -49,7 +49,11 @@ final class DirectAdminVacationSyncService
|
||||
$next = [];
|
||||
foreach ($targets as $mailbox) {
|
||||
$email = strtolower((string)$mailbox['email']);
|
||||
$this->api->saveVacation($username, $email, $rule, isset($existing[$email]));
|
||||
$isTracked = isset($existing[$email]);
|
||||
if (!$isTracked && $this->api->vacationExists($username, $email)) {
|
||||
throw new RuntimeException('Untracked DirectAdmin vacation already exists for ' . $email);
|
||||
}
|
||||
$this->api->saveVacation($username, $email, $rule, $isTracked);
|
||||
$next[] = [
|
||||
'rule_id' => $ruleId,
|
||||
'domain' => (string)$mailbox['domain'],
|
||||
|
||||
@@ -52,5 +52,13 @@ return [
|
||||
'Back' => 'Back',
|
||||
'Rule details' => 'Rule details',
|
||||
'Selected' => 'Selected',
|
||||
'Mon' => 'Mon',
|
||||
'Tue' => 'Tue',
|
||||
'Wed' => 'Wed',
|
||||
'Thu' => 'Thu',
|
||||
'Fri' => 'Fri',
|
||||
'Sat' => 'Sat',
|
||||
'Sun' => 'Sun',
|
||||
'DirectAdmin backend supports only one enabled global rule.' => 'DirectAdmin backend supports only one enabled global rule.',
|
||||
'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.' => 'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.',
|
||||
];
|
||||
|
||||
@@ -52,5 +52,13 @@ return [
|
||||
'Back' => 'Powrót',
|
||||
'Rule details' => 'Szczegóły reguły',
|
||||
'Selected' => 'Wybrano',
|
||||
'Mon' => 'Pn',
|
||||
'Tue' => 'Wt',
|
||||
'Wed' => 'Śr',
|
||||
'Thu' => 'Cz',
|
||||
'Fri' => 'Pt',
|
||||
'Sat' => 'Sb',
|
||||
'Sun' => 'Nd',
|
||||
'DirectAdmin backend supports only one enabled global rule.' => 'Backend DirectAdmin obsługuje tylko jedną aktywną regułę globalną.',
|
||||
'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.' => 'Usunięcie reguły wyłączy odpowiedź automatyczną utworzoną przez tę regułę dla objętych skrzynek.',
|
||||
];
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name=global-autoresponder
|
||||
id=global-autoresponder
|
||||
type=user
|
||||
author=HITME.PL
|
||||
version=1.0.6
|
||||
version=1.0.7
|
||||
active=no
|
||||
installed=no
|
||||
user_run_as=root
|
||||
|
||||
@@ -3,6 +3,7 @@ set -eu
|
||||
|
||||
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
|
||||
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
|
||||
CRON_FILE="/etc/cron.d/global-autoresponder"
|
||||
|
||||
mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs"
|
||||
chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs"
|
||||
@@ -20,4 +21,7 @@ fi
|
||||
|
||||
touch "$BASE_DIR/logs/audit.log"
|
||||
chmod 600 "$BASE_DIR/logs/audit.log"
|
||||
|
||||
printf '%s\n' "*/15 * * * * root $PLUGIN_DIR/scripts/sync_directadmin_vacations.php >/dev/null 2>&1" > "$CRON_FILE"
|
||||
chmod 644 "$CRON_FILE"
|
||||
echo "global-autoresponder installed"
|
||||
|
||||
@@ -11,6 +11,7 @@ mkdir -p "$ROOT_DIR/archives/$VERSION"
|
||||
tar -czf "$ROOT_DIR/archives/$VERSION/global-autoresponder.tar.gz" \
|
||||
--exclude='./.git' \
|
||||
--exclude='./tests' \
|
||||
--exclude='./scripts/package.sh' \
|
||||
--exclude='./data' \
|
||||
--exclude='./state' \
|
||||
--exclude='./logs' \
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
set -eu
|
||||
|
||||
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
|
||||
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
|
||||
CRON_FILE="/etc/cron.d/global-autoresponder"
|
||||
|
||||
if [ -x "$PLUGIN_DIR/scripts/backend.sh" ]; then
|
||||
"$PLUGIN_DIR/scripts/backend.sh" da || true
|
||||
fi
|
||||
rm -f "$CRON_FILE"
|
||||
|
||||
if [ "${PURGE_GLOBAL_AUTORESPONDER_DATA:-0}" = "1" ]; then
|
||||
rm -rf "$BASE_DIR"
|
||||
echo "global-autoresponder data purged"
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
set -eu
|
||||
|
||||
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
|
||||
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
|
||||
CRON_FILE="/etc/cron.d/global-autoresponder"
|
||||
mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs"
|
||||
printf '%s\n' "*/15 * * * * root $PLUGIN_DIR/scripts/sync_directadmin_vacations.php >/dev/null 2>&1" > "$CRON_FILE"
|
||||
chmod 644 "$CRON_FILE"
|
||||
echo "global-autoresponder updated; external configuration preserved"
|
||||
|
||||
@@ -18,3 +18,15 @@ test('directadmin vacation api builds scoped payload for one mailbox', function
|
||||
assert_same('morning', $payload['starttime']);
|
||||
assert_same('evening', $payload['endtime']);
|
||||
});
|
||||
|
||||
test('directadmin vacation api detects existing native vacation by mailbox', function (): void {
|
||||
$api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin', static function (string $owner, string $endpoint, array $payload): string {
|
||||
assert_same('alice', $owner);
|
||||
assert_same('CMD_API_EMAIL_VACATION', $endpoint);
|
||||
assert_same(['domain' => 'example.com'], $payload);
|
||||
return 'info=starttime%3Dmorning%26text%3DBody&sales=starttime%3Devening';
|
||||
});
|
||||
|
||||
assert_true($api->vacationExists('alice', 'info@example.com'));
|
||||
assert_false($api->vacationExists('alice', 'new@example.com'));
|
||||
});
|
||||
|
||||
@@ -28,6 +28,10 @@ test('directadmin vacation sync creates tracked entries for enabled rule mailbox
|
||||
$calls = [];
|
||||
$api = new class($calls) extends DirectAdminVacationApi {
|
||||
public function __construct(private array &$calls) {}
|
||||
public function vacationExists(string $owner, string $email): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
public function saveVacation(string $owner, string $email, array $rule, bool $exists): void
|
||||
{
|
||||
$this->calls[] = ['save', $owner, $email, $exists, $rule['subject']];
|
||||
@@ -94,3 +98,52 @@ test('directadmin vacation sync deletes stale tracked entries after rule removal
|
||||
assert_same([['delete', 'alice', 'info@example.com']], $calls);
|
||||
assert_same([], $syncRepo->entriesForRule('alice', (string)$rule['id']));
|
||||
});
|
||||
|
||||
test('directadmin vacation sync refuses to overwrite untracked native vacation', function (): void {
|
||||
$virtualRoot = TEST_TMP . '/da-sync-conflict-virtual';
|
||||
test_write($virtualRoot . '/domainowners', "example.com: alice\n");
|
||||
test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n");
|
||||
|
||||
$dataDir = TEST_TMP . '/da-sync-conflict-data';
|
||||
$repo = new RuleRepository($dataDir);
|
||||
$repo->create('alice', [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_value' => '2026-06-10|morning',
|
||||
'end_value' => '2026-06-24|evening',
|
||||
'start_ts' => 1,
|
||||
'end_ts' => 2,
|
||||
'enabled' => true,
|
||||
'dynamic_scope' => true,
|
||||
]);
|
||||
|
||||
$calls = [];
|
||||
$api = new class($calls) extends DirectAdminVacationApi {
|
||||
public function __construct(private array &$calls) {}
|
||||
public function vacationExists(string $owner, string $email): bool
|
||||
{
|
||||
return $email === 'info@example.com';
|
||||
}
|
||||
public function saveVacation(string $owner, string $email, array $rule, bool $exists): void
|
||||
{
|
||||
$this->calls[] = ['save', $owner, $email];
|
||||
}
|
||||
};
|
||||
|
||||
$sync = new DirectAdminVacationSyncService(
|
||||
$repo,
|
||||
new DirectAdminSyncRepository($dataDir),
|
||||
new MailboxDirectory($virtualRoot),
|
||||
$api
|
||||
);
|
||||
|
||||
$thrown = false;
|
||||
try {
|
||||
$sync->syncUser('alice');
|
||||
} catch (RuntimeException $e) {
|
||||
$thrown = true;
|
||||
assert_contains('Untracked DirectAdmin vacation already exists', $e->getMessage());
|
||||
}
|
||||
assert_true($thrown);
|
||||
assert_same([], $calls);
|
||||
});
|
||||
|
||||
@@ -112,3 +112,58 @@ test('calendar picker is based on selected rule month and supports month navigat
|
||||
assert_contains('Luty 2027', $html);
|
||||
assert_false(strpos($html, 'Czerwiec 2026') !== false);
|
||||
});
|
||||
|
||||
test('rule form localizes calendar and preview labels without translating user content globally', function (): void {
|
||||
$settingsPath = TEST_TMP . '/form-i18n.conf';
|
||||
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\nGLOBAL_AUTORESPONDER_BACKEND=exim\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$ctx = new AppContext(
|
||||
new DirectAdminUser('alice', 'enhanced', 'en', TEST_TMP . '/config'),
|
||||
$settings,
|
||||
new RuleRepository(TEST_TMP . '/data'),
|
||||
new CsrfGuard('secret', 'alice', 'sid'),
|
||||
Lang::load('en', $settings),
|
||||
new AuditLog(TEST_TMP . '/audit.log')
|
||||
);
|
||||
|
||||
$html = render_rule_form($ctx, 'create', null);
|
||||
assert_contains('<th>Mon</th>', $html);
|
||||
assert_contains('Selected:', $html);
|
||||
assert_false(strpos($html, 'Wybrano') !== false);
|
||||
assert_false(strpos($html, '<th>Pn</th>') !== false);
|
||||
|
||||
$preview = render_preview_box($ctx, ['subject' => 'Delete', 'body' => 'Cancel']);
|
||||
assert_contains('<strong>Subject:</strong> Delete', $preview);
|
||||
assert_contains('Cancel', $preview);
|
||||
});
|
||||
|
||||
test('directadmin backend allows only one enabled global rule per account', function (): void {
|
||||
$settingsPath = TEST_TMP . '/form-da-single.conf';
|
||||
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=da\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$repo = new RuleRepository(TEST_TMP . '/single-data');
|
||||
$repo->create('alice', [
|
||||
'subject' => 'Pierwsza',
|
||||
'body' => 'Body',
|
||||
'enabled' => true,
|
||||
'start_value' => '2026-06-10|morning',
|
||||
'end_value' => '2026-06-24|evening',
|
||||
]);
|
||||
$ctx = new AppContext(
|
||||
new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'),
|
||||
$settings,
|
||||
$repo,
|
||||
new CsrfGuard('secret', 'alice', 'sid'),
|
||||
Lang::load('pl', $settings),
|
||||
new AuditLog(TEST_TMP . '/audit.log')
|
||||
);
|
||||
|
||||
$thrown = false;
|
||||
try {
|
||||
enforce_rule_backend_constraints($ctx, null, ['enabled' => true]);
|
||||
} catch (RuntimeException $e) {
|
||||
$thrown = true;
|
||||
assert_contains('Backend DirectAdmin obsługuje tylko jedną aktywną regułę globalną.', $e->getMessage());
|
||||
}
|
||||
assert_true($thrown);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ test('packing guidelines exclude docs and allow later manual icon', function ():
|
||||
assert_contains('docs/dokumentacja.html', $packing);
|
||||
assert_contains('must not contain `docs/`', $packing);
|
||||
assert_contains('may be added later at `src/images/user_icon.svg`', $packing);
|
||||
assert_contains("scripts/package.sh", $packing);
|
||||
});
|
||||
|
||||
test('plugin metadata runs user level as root on directadmin 1.689 plus', function (): void {
|
||||
@@ -44,3 +45,15 @@ test('backend script protects custom exim config with rollback and exact section
|
||||
assert_contains('if ! awk -v section="$section" -v snippet="$snippet"', $script);
|
||||
assert_contains('if (inserted != 1)', $script);
|
||||
});
|
||||
|
||||
test('lifecycle scripts install cron sync and remove exim integration on uninstall', function (): void {
|
||||
$install = file_get_contents(PLUGIN_ROOT . '/scripts/install.sh') ?: '';
|
||||
$update = file_get_contents(PLUGIN_ROOT . '/scripts/update.sh') ?: '';
|
||||
$uninstall = file_get_contents(PLUGIN_ROOT . '/scripts/uninstall.sh') ?: '';
|
||||
|
||||
assert_contains('/etc/cron.d/global-autoresponder', $install);
|
||||
assert_contains('sync_directadmin_vacations.php', $install);
|
||||
assert_contains('/etc/cron.d/global-autoresponder', $update);
|
||||
assert_contains('sync_directadmin_vacations.php', $update);
|
||||
assert_contains('backend.sh" da', $uninstall);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user