From 64b62a5793ebb8095b9c2de66858e42fdbf0f6fd Mon Sep 17 00:00:00 2001 From: Marek Miklewicz Date: Tue, 2 Jun 2026 21:06:25 +0200 Subject: [PATCH] fix: harden backend synchronization --- exec/handlers/_form_helpers.php | 34 ++++++++++-- exec/handlers/create.php | 10 +++- exec/handlers/delete.php | 2 +- exec/handlers/preview.php | 2 +- exec/handlers/toggle.php | 4 +- exec/handlers/update.php | 1 + exec/lib/AppContext.php | 2 +- exec/lib/DirectAdminVacationApi.php | 41 ++++++++++++-- exec/lib/DirectAdminVacationSyncService.php | 6 +- lang/en.php | 8 +++ lang/pl.php | 8 +++ plugin.conf | 2 +- scripts/install.sh | 4 ++ scripts/package.sh | 1 + scripts/uninstall.sh | 8 +++ scripts/update.sh | 4 ++ tests/directadmin_vacation_api_test.php | 12 ++++ ...directadmin_vacation_sync_service_test.php | 53 ++++++++++++++++++ tests/handler_test.php | 55 +++++++++++++++++++ tests/package_contents_test.php | 13 +++++ 20 files changed, 254 insertions(+), 16 deletions(-) diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php index 73c1056..5ff5347 100644 --- a/exec/handlers/_form_helpers.php +++ b/exec/handlers/_form_helpers.php @@ -50,6 +50,23 @@ function attach_rule_scope(AppContext $ctx, array $rule): array return $rule; } +/** @param array $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 ''; } return '

' . AppContext::e($title) . '

' . AppContext::e($title) . '

' . AppContext::e($monthLabel) . '
' . - '' . $days . '
PnWtŚrCzPtSbNd
' . - '
Wybrano: ' . AppContext::e($selectedDate) . '
' . + '' . render_weekday_headers($ctx) . '' . $days . '
' . + '
' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '
' . '

' . AppContext::e($ctx->t('Time')) . '

' . $timeControl . '
' . ''; } -function render_preview_box(array $rule): string +function render_preview_box(AppContext $ctx, array $rule): string { - return '
Temat: ' . AppContext::e((string)$rule['subject']) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; + return '
' . AppContext::e($ctx->t('Subject')) . ': ' . AppContext::e((string)$rule['subject']) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; +} + +function render_weekday_headers(AppContext $ctx): string +{ + $html = ''; + foreach (['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day) { + $html .= '' . AppContext::e($ctx->t($day)) . ''; + } + return $html; } function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string diff --git a/exec/handlers/create.php b/exec/handlers/create.php index 5d69f1e..5661994 100644 --- a/exec/handlers/create.php +++ b/exec/handlers/create.php @@ -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); - $ctx->syncActiveBackend(); + 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(); diff --git a/exec/handlers/delete.php b/exec/handlers/delete.php index a37462b..9a2d9f0 100644 --- a/exec/handlers/delete.php +++ b/exec/handlers/delete.php @@ -20,7 +20,7 @@ return static function (AppContext $ctx): void { return; } } - $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($rule) . '
' . + $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($ctx, $rule) . '
' . '

' . AppContext::e($ctx->t('Confirm deletion')) . '

' . AppContext::e($ctx->t('Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.')) . '
' . '
' . $ctx->csrfField('delete:' . $id) . '' . '
' . AppContext::e($ctx->t('Do not delete')) . '
'; diff --git a/exec/handlers/preview.php b/exec/handlers/preview.php index da53271..618f88a 100644 --- a/exec/handlers/preview.php +++ b/exec/handlers/preview.php @@ -8,7 +8,7 @@ return static function (AppContext $ctx): void { $ctx->render('Preview autoresponder', '
' . AppContext::e($ctx->t('Rule not found')) . '
'); return; } - $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($rule) . '
' . + $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($ctx, $rule) . '
' . '

' . AppContext::e($ctx->t('Rule details')) . '

' . AppContext::e($ctx->t('Status')) . '' . AppContext::e($ctx->t(!empty($rule['enabled']) ? 'Enabled' : 'Disabled')) . '
' . '
' . AppContext::e($ctx->t('Sending period')) . '' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts']) . ' - ' . date('Y-m-d H:i', (int)$rule['end_ts'])) . '
' . '
'; diff --git a/exec/handlers/toggle.php b/exec/handlers/toggle.php index c7869ee..72c800d 100644 --- a/exec/handlers/toggle.php +++ b/exec/handlers/toggle.php @@ -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'])); }; diff --git a/exec/handlers/update.php b/exec/handlers/update.php index 8e06e94..f360fb8 100644 --- a/exec/handlers/update.php +++ b/exec/handlers/update.php @@ -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'])); diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php index a7fbad6..911527d 100644 --- a/exec/lib/AppContext.php +++ b/exec/lib/AppContext.php @@ -101,7 +101,7 @@ final class AppContext echo '

' . self::e($this->t($title)) . '

' . self::e($this->t('Manage automatic replies for account mailboxes')) . '

'; echo ''; - echo '
' . $alerts . $this->lang->translateHtml($body) . '
'; + echo '
' . $alerts . $body . '
'; echo ''; } diff --git a/exec/lib/DirectAdminVacationApi.php b/exec/lib/DirectAdminVacationApi.php index 93dc8cb..0bf4475 100644 --- a/exec/lib/DirectAdminVacationApi.php +++ b/exec/lib/DirectAdminVacationApi.php @@ -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 $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 diff --git a/exec/lib/DirectAdminVacationSyncService.php b/exec/lib/DirectAdminVacationSyncService.php index 07029cd..e5c6b0c 100644 --- a/exec/lib/DirectAdminVacationSyncService.php +++ b/exec/lib/DirectAdminVacationSyncService.php @@ -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'], diff --git a/lang/en.php b/lang/en.php index 8ea9bc7..69b6c45 100644 --- a/lang/en.php +++ b/lang/en.php @@ -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.', ]; diff --git a/lang/pl.php b/lang/pl.php index 097ec4a..5f9678a 100644 --- a/lang/pl.php +++ b/lang/pl.php @@ -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.', ]; diff --git a/plugin.conf b/plugin.conf index 97b479e..a3bf457 100644 --- a/plugin.conf +++ b/plugin.conf @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index df9eccb..091aab6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" diff --git a/scripts/package.sh b/scripts/package.sh index 61760e6..4fd5cce 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -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' \ diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 892666d..88ddc48 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -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" diff --git a/scripts/update.sh b/scripts/update.sh index e20a5d1..1314b02 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -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" diff --git a/tests/directadmin_vacation_api_test.php b/tests/directadmin_vacation_api_test.php index 6985930..6d0a3f0 100644 --- a/tests/directadmin_vacation_api_test.php +++ b/tests/directadmin_vacation_api_test.php @@ -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')); +}); diff --git a/tests/directadmin_vacation_sync_service_test.php b/tests/directadmin_vacation_sync_service_test.php index 140da85..8e33c2d 100644 --- a/tests/directadmin_vacation_sync_service_test.php +++ b/tests/directadmin_vacation_sync_service_test.php @@ -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); +}); diff --git a/tests/handler_test.php b/tests/handler_test.php index 9cb7cf7..4f0879b 100644 --- a/tests/handler_test.php +++ b/tests/handler_test.php @@ -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('Mon', $html); + assert_contains('Selected:', $html); + assert_false(strpos($html, 'Wybrano') !== false); + assert_false(strpos($html, 'Pn') !== false); + + $preview = render_preview_box($ctx, ['subject' => 'Delete', 'body' => 'Cancel']); + assert_contains('Subject: 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); +}); diff --git a/tests/package_contents_test.php b/tests/package_contents_test.php index 7cda04f..882e1f9 100644 --- a/tests/package_contents_test.php +++ b/tests/package_contents_test.php @@ -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); +});