]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Доработка документации добавление схемы БД
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 24 Dec 2025 09:31:05 +0000 (12:31 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 24 Dec 2025 09:31:05 +0000 (12:31 +0300)
36 files changed:
.gitignore
erp24/tests/_data/kogort_stop_list.php [new file with mode: 0644]
erp24/tests/fixtures/KogortStopListFixture.php [new file with mode: 0644]
erp24/tests/unit/config/AmoCrmSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/AmoCrmTokenPathTest.php [new file with mode: 0644]
erp24/tests/unit/config/BasicAuthSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/BonusPlusSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/CloudPaymentsSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/ConfigIncSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/CookieValidationKeyConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/DatabaseConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/DbRemoteConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/DbTestSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/DockerSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/DocumentationSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/DomruCamerasSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/EnvConfigurationTest.php [new file with mode: 0644]
erp24/tests/unit/config/EnvExampleCompletenessTest.php [new file with mode: 0644]
erp24/tests/unit/config/GreenSmsSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/ImapSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/LegacyDbSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/LegacyTelegramBotsSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/MediaConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/NoHardcodedSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/config/ParamsConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/RabbitMqConfigTest.php [new file with mode: 0644]
erp24/tests/unit/config/StartupConfigSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/controllers/TelegramControllerConfigTest.php [new file with mode: 0644]
erp24/tests/unit/controllers/TelegramSalebotControllerConfigTest.php [new file with mode: 0644]
erp24/tests/unit/models/UsersFilterTelegramUsersForSendingTest.php [new file with mode: 0644]
erp24/tests/unit/records/LPTrackerApiSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/services/MarketplaceServiceSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/services/SiteServiceSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/services/TelegramServiceSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/services/TelegramTargetSecretsTest.php [new file with mode: 0644]
erp24/tests/unit/services/WhatsAppServiceSecretsTest.php [new file with mode: 0644]

index be398223eba5eba3cee52b65c1ebab475c775e5f..25f996350a84c41caaa95085e3eee014b6a6c552 100644 (file)
@@ -97,3 +97,4 @@ coordination/orchestration/*
 claude-flow
 # Removed Windows wrapper files per user request
 hive-mind-prompt-*.txt
+/.claude/
diff --git a/erp24/tests/_data/kogort_stop_list.php b/erp24/tests/_data/kogort_stop_list.php
new file mode 100644 (file)
index 0000000..836ea4b
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+return [
+    'stop1' => [
+        'id' => 1,
+        'phone' => '79990000001',
+        'comment' => 'test',
+        'created_at' => '2024-01-01 00:00:00',
+        'updated_at' => '2024-01-01 00:00:00',
+        'created_by' => 1,
+        'updated_by' => 1,
+    ],
+    'stop2' => [
+        'id' => 2,
+        'phone' => '79990000002',
+        'comment' => 'test',
+        'created_at' => '2024-01-01 00:00:00',
+        'updated_at' => '2024-01-01 00:00:00',
+        'created_by' => 1,
+        'updated_by' => 1,
+    ],
+];
+
+
+
+
+
+
+
+
+
+
diff --git a/erp24/tests/fixtures/KogortStopListFixture.php b/erp24/tests/fixtures/KogortStopListFixture.php
new file mode 100644 (file)
index 0000000..f164e5d
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace tests\fixtures;
+
+use yii\test\ActiveFixture;
+
+class KogortStopListFixture extends ActiveFixture
+{
+    public $modelClass = 'yii_app\\records\\KogortStopList';
+    public $dataFile = __DIR__ . '/../_data/kogort_stop_list.php';
+}
+
+
+
+
+
+
+
+
+
+
diff --git a/erp24/tests/unit/config/AmoCrmSecretsTest.php b/erp24/tests/unit/config/AmoCrmSecretsTest.php
new file mode 100644 (file)
index 0000000..0d07a48
--- /dev/null
@@ -0,0 +1,371 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for AmoCRM configuration secrets
+ *
+ * Verifies that AmoCRM integration files use environment variables
+ * for sensitive data instead of hardcoded values.
+ *
+ * Covers:
+ * - erp24/inc/amo/amo_inc.php
+ * - erp24/inc/amo/get_token.php
+ *
+ * @group config
+ * @group amo
+ * @group secrets
+ */
+class AmoCrmSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // amo_inc.php tests
+    // ============================================================
+
+    /**
+     * Test amo_inc.php exists
+     */
+    public function testAmoIncFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $this->assertFileExists($filePath, 'inc/amo/amo_inc.php must exist');
+    }
+
+    /**
+     * Test amo_inc.php is valid PHP
+     */
+    public function testAmoIncIsValidPhp(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $output = [];
+        $returnCode = 0;
+        exec("php -l " . escapeshellarg($filePath) . " 2>&1", $output, $returnCode);
+        $this->assertEquals(0, $returnCode, 'amo_inc.php must be valid PHP: ' . implode("\n", $output));
+    }
+
+    /**
+     * Test amo_inc.php uses AMO_TOKEN_FILE_INC env
+     */
+    public function testAmoIncUsesTokenFileEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_TOKEN_FILE_INC[\'"]\s*\)/',
+            $content,
+            'amo_inc.php must use getenv(\'AMO_TOKEN_FILE_INC\')'
+        );
+    }
+
+    /**
+     * Test amo_inc.php uses AMO_SUBDOMAIN env
+     */
+    public function testAmoIncUsesSubdomainEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SUBDOMAIN[\'"]\s*\)/',
+            $content,
+            'amo_inc.php must use getenv(\'AMO_SUBDOMAIN\')'
+        );
+    }
+
+    /**
+     * Test amo_inc.php has no hardcoded tokens
+     */
+    public function testAmoIncNoHardcodedTokens(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        // Should not contain hardcoded access_token assignment
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$ACCESS_TOKEN\s*=\s*[\'"][a-zA-Z0-9]{20,}[\'"]/',
+            $content,
+            'amo_inc.php must not contain hardcoded ACCESS_TOKEN'
+        );
+    }
+
+    /**
+     * Test amo_inc.php has PHP doc
+     */
+    public function testAmoIncHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'amo_inc.php must have documentation comment'
+        );
+    }
+
+    /**
+     * Test amo_inc.php references .env.example
+     */
+    public function testAmoIncReferencesEnvExample(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            '.env.example',
+            $content,
+            'amo_inc.php must reference .env.example'
+        );
+    }
+
+    // ============================================================
+    // get_token.php tests
+    // ============================================================
+
+    /**
+     * Test get_token.php exists
+     */
+    public function testGetTokenFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $this->assertFileExists($filePath, 'inc/amo/get_token.php must exist');
+    }
+
+    /**
+     * Test get_token.php uses AMO_SECRET_PHRASE env
+     */
+    public function testGetTokenUsesSecretPhraseEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SECRET_PHRASE[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_SECRET_PHRASE\')'
+        );
+    }
+
+    /**
+     * Test get_token.php uses AMO_SUBDOMAIN env
+     */
+    public function testGetTokenUsesSubdomainEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SUBDOMAIN[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_SUBDOMAIN\')'
+        );
+    }
+
+    /**
+     * Test get_token.php uses AMO_APP_URL env
+     */
+    public function testGetTokenUsesAppUrlEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_APP_URL[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_APP_URL\')'
+        );
+    }
+
+    /**
+     * Test get_token.php uses AMO_CLIENT_ID env
+     */
+    public function testGetTokenUsesClientIdEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_CLIENT_ID[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_CLIENT_ID\')'
+        );
+    }
+
+    /**
+     * Test get_token.php uses AMO_CLIENT_SECRET env
+     */
+    public function testGetTokenUsesClientSecretEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_CLIENT_SECRET[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_CLIENT_SECRET\')'
+        );
+    }
+
+    /**
+     * Test get_token.php uses AMO_TOKEN_FILE env
+     */
+    public function testGetTokenUsesTokenFileEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_TOKEN_FILE[\'"]\s*\)/',
+            $content,
+            'get_token.php must use getenv(\'AMO_TOKEN_FILE\')'
+        );
+    }
+
+    /**
+     * Test get_token.php has no hardcoded client secrets
+     */
+    public function testGetTokenNoHardcodedClientSecret(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Should not contain hardcoded CLIENT_SECRET
+        $this->assertDoesNotMatchRegularExpression(
+            '/define\s*\(\s*[\'"]CLIENT_SECRET[\'"]\s*,\s*[\'"][a-zA-Z0-9]{10,}[\'"]/',
+            $content,
+            'get_token.php must not contain hardcoded CLIENT_SECRET'
+        );
+    }
+
+    /**
+     * Test get_token.php has no hardcoded client ID
+     */
+    public function testGetTokenNoHardcodedClientId(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Should not contain hardcoded CLIENT_ID (UUID format)
+        $this->assertDoesNotMatchRegularExpression(
+            '/define\s*\(\s*[\'"]CLIENT_ID[\'"]\s*,\s*[\'"][a-f0-9-]{36}[\'"]/',
+            $content,
+            'get_token.php must not contain hardcoded CLIENT_ID'
+        );
+    }
+
+    /**
+     * Test get_token.php has PHP doc
+     */
+    public function testGetTokenHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'get_token.php must have documentation comment'
+        );
+    }
+
+    /**
+     * Test get_token.php references .env.example
+     */
+    public function testGetTokenReferencesEnvExample(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            '.env.example',
+            $content,
+            'get_token.php must reference .env.example'
+        );
+    }
+
+    /**
+     * Test get_token.php validates secret_phrase
+     */
+    public function testGetTokenValidatesSecretPhrase(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Should validate secret_phrase from $_GET
+        $this->assertMatchesRegularExpression(
+            '/\$_GET\s*\[\s*[\'"]secret_phrase[\'"]\s*\]/',
+            $content,
+            'get_token.php must validate secret_phrase from GET parameter'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests for AmoCRM
+    // ============================================================
+
+    /**
+     * Test .env.example has AMO_SUBDOMAIN
+     */
+    public function testEnvExampleHasAmoSubdomain(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO_SUBDOMAIN=',
+            $envExample,
+            '.env.example must contain AMO_SUBDOMAIN'
+        );
+    }
+
+    /**
+     * Test .env.example has AMO_CLIENT_ID
+     */
+    public function testEnvExampleHasAmoClientId(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO_CLIENT_ID=',
+            $envExample,
+            '.env.example must contain AMO_CLIENT_ID'
+        );
+    }
+
+    /**
+     * Test .env.example has AMO_CLIENT_SECRET
+     */
+    public function testEnvExampleHasAmoClientSecret(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO_CLIENT_SECRET=',
+            $envExample,
+            '.env.example must contain AMO_CLIENT_SECRET'
+        );
+    }
+
+    /**
+     * Test .env.example has AMO_SECRET_PHRASE
+     */
+    public function testEnvExampleHasAmoSecretPhrase(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO_SECRET_PHRASE=',
+            $envExample,
+            '.env.example must contain AMO_SECRET_PHRASE'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/AmoCrmTokenPathTest.php b/erp24/tests/unit/config/AmoCrmTokenPathTest.php
new file mode 100644 (file)
index 0000000..1b2640e
--- /dev/null
@@ -0,0 +1,410 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for AmoCRM token path migration
+ *
+ * Verifies that AmoCRM integration files use environment variables
+ * for token file paths and OAuth credentials, preventing secrets
+ * from being stored in the repository.
+ *
+ * Covers:
+ * - erp24/inc/amo_inc.php
+ * - erp24/inc/amo/amo_inc.php
+ * - erp24/inc/amo2/amo_inc.php
+ * - erp24/inc/amo/get_token.php
+ * - erp24/inc/amo2/get_token.php
+ *
+ * @group config
+ * @group amocrm
+ * @group secrets
+ */
+class AmoCrmTokenPathTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // inc/amo_inc.php tests
+    // ============================================================
+
+    /**
+     * Test amo_inc.php exists
+     */
+    public function testAmoIncFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo_inc.php';
+        $this->assertFileExists($filePath, 'inc/amo_inc.php must exist');
+    }
+
+    /**
+     * Test amo_inc.php uses AMO_TOKEN_FILE env
+     */
+    public function testAmoIncUsesTokenFileEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_TOKEN_FILE[\'"]\s*\)/',
+            $content,
+            'amo_inc.php must use getenv(\'AMO_TOKEN_FILE\')'
+        );
+    }
+
+    /**
+     * Test amo_inc.php uses AMO_SUBDOMAIN env
+     */
+    public function testAmoIncUsesSubdomainEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SUBDOMAIN[\'"]\s*\)/',
+            $content,
+            'amo_inc.php must use getenv(\'AMO_SUBDOMAIN\')'
+        );
+    }
+
+    /**
+     * Test amo_inc.php has PHP doc
+     */
+    public function testAmoIncHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'amo_inc.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // inc/amo/amo_inc.php tests
+    // ============================================================
+
+    /**
+     * Test amo/amo_inc.php exists
+     */
+    public function testAmoAmoIncFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $this->assertFileExists($filePath, 'inc/amo/amo_inc.php must exist');
+    }
+
+    /**
+     * Test amo/amo_inc.php uses AMO_TOKEN_FILE_INC env
+     */
+    public function testAmoAmoIncUsesTokenFileEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_TOKEN_FILE_INC[\'"]\s*\)/',
+            $content,
+            'amo/amo_inc.php must use getenv(\'AMO_TOKEN_FILE_INC\')'
+        );
+    }
+
+    // ============================================================
+    // inc/amo2/amo_inc.php tests
+    // ============================================================
+
+    /**
+     * Test amo2/amo_inc.php exists
+     */
+    public function testAmo2AmoIncFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/amo_inc.php';
+        $this->assertFileExists($filePath, 'inc/amo2/amo_inc.php must exist');
+    }
+
+    /**
+     * Test amo2/amo_inc.php uses AMO2_TOKEN_FILE env
+     */
+    public function testAmo2AmoIncUsesTokenFileEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO2_TOKEN_FILE[\'"]\s*\)/',
+            $content,
+            'amo2/amo_inc.php must use getenv(\'AMO2_TOKEN_FILE\')'
+        );
+    }
+
+    /**
+     * Test amo2/amo_inc.php has PHP doc
+     */
+    public function testAmo2AmoIncHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/amo_inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'amo2/amo_inc.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // inc/amo2/get_token.php tests
+    // ============================================================
+
+    /**
+     * Test amo2/get_token.php exists
+     */
+    public function testAmo2GetTokenFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $this->assertFileExists($filePath, 'inc/amo2/get_token.php must exist');
+    }
+
+    /**
+     * Test amo2/get_token.php uses AMO2_CLIENT_ID env
+     */
+    public function testAmo2GetTokenUsesClientIdEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO2_CLIENT_ID[\'"]\s*\)/',
+            $content,
+            'amo2/get_token.php must use getenv(\'AMO2_CLIENT_ID\')'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php uses AMO2_CLIENT_SECRET env
+     */
+    public function testAmo2GetTokenUsesClientSecretEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO2_CLIENT_SECRET[\'"]\s*\)/',
+            $content,
+            'amo2/get_token.php must use getenv(\'AMO2_CLIENT_SECRET\')'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php uses AMO2_SECRET_PHRASE env
+     */
+    public function testAmo2GetTokenUsesSecretPhraseEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO2_SECRET_PHRASE[\'"]\s*\)/',
+            $content,
+            'amo2/get_token.php must use getenv(\'AMO2_SECRET_PHRASE\')'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php has no hardcoded CLIENT_ID
+     */
+    public function testAmo2GetTokenNoHardcodedClientId(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked CLIENT_ID
+        $this->assertStringNotContainsString(
+            'a6156015-990f-4bbc-8fd1-309d51347407',
+            $content,
+            'amo2/get_token.php must not contain hardcoded CLIENT_ID'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php has no hardcoded CLIENT_SECRET
+     */
+    public function testAmo2GetTokenNoHardcodedClientSecret(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked CLIENT_SECRET
+        $this->assertStringNotContainsString(
+            'Z0mo0XadAR44kCEAOw5XvECLA8QbFYeLyHnCM81b9bKC96LjzTDd80cItLZ2wzmO',
+            $content,
+            'amo2/get_token.php must not contain hardcoded CLIENT_SECRET'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php has no hardcoded SECRET_PHRASE
+     */
+    public function testAmo2GetTokenNoHardcodedSecretPhrase(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked SECRET_PHRASE
+        $this->assertStringNotContainsString(
+            'VJJVkt467ltuXU__356XEtS',
+            $content,
+            'amo2/get_token.php must not contain hardcoded SECRET_PHRASE'
+        );
+    }
+
+    /**
+     * Test amo2/get_token.php has PHP doc
+     */
+    public function testAmo2GetTokenHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/amo2/get_token.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'amo2/get_token.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // Token file security tests
+    // ============================================================
+
+    /**
+     * Test no token JSON files in repository
+     */
+    public function testNoTokenFilesInRepo(): void
+    {
+        $tokenPatterns = [
+            $this->basePath . '/inc/token_amo*.json',
+            $this->basePath . '/inc/amo/token_*.json',
+            $this->basePath . '/inc/amo2/token_*.json',
+            $this->basePath . '/inc/amo2/1token_*.json',
+        ];
+
+        foreach ($tokenPatterns as $pattern) {
+            $files = glob($pattern);
+            // Files may exist locally but should not be committed
+            // This test verifies they are listed in .gitignore
+        }
+
+        // Check .gitignore includes token patterns
+        $gitignore = file_get_contents(dirname($this->basePath) . '/.gitignore');
+
+        $this->assertStringContainsString(
+            'erp24/inc/amo/*.json',
+            $gitignore,
+            '.gitignore must exclude erp24/inc/amo/*.json'
+        );
+
+        $this->assertStringContainsString(
+            'erp24/inc/amo2/*.json',
+            $gitignore,
+            '.gitignore must exclude erp24/inc/amo2/*.json'
+        );
+
+        $this->assertStringContainsString(
+            'erp24/inc/token_amo*.json',
+            $gitignore,
+            '.gitignore must exclude erp24/inc/token_amo*.json'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests
+    // ============================================================
+
+    /**
+     * Test .env.example has AMO2_CLIENT_ID
+     */
+    public function testEnvExampleHasAmo2ClientId(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO2_CLIENT_ID=',
+            $envExample,
+            '.env.example must contain AMO2_CLIENT_ID'
+        );
+    }
+
+    /**
+     * Test .env.example has AMO2_CLIENT_SECRET
+     */
+    public function testEnvExampleHasAmo2ClientSecret(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO2_CLIENT_SECRET=',
+            $envExample,
+            '.env.example must contain AMO2_CLIENT_SECRET'
+        );
+    }
+
+    /**
+     * Test .env.example has AMO2_TOKEN_FILE
+     */
+    public function testEnvExampleHasAmo2TokenFile(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'AMO2_TOKEN_FILE=',
+            $envExample,
+            '.env.example must contain AMO2_TOKEN_FILE'
+        );
+    }
+
+    /**
+     * Test .env.example token paths point outside repository
+     */
+    public function testEnvExampleTokenPathsOutsideRepo(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        // Token paths should point to /var/www/secrets/ or similar
+        $this->assertMatchesRegularExpression(
+            '/AMO_TOKEN_FILE=\/var\/www\/secrets\//',
+            $envExample,
+            '.env.example AMO_TOKEN_FILE should point to /var/www/secrets/'
+        );
+    }
+
+    /**
+     * Test no leaked AmoCRM secrets in .env.example
+     */
+    public function testEnvExampleNoLeakedAmoSecrets(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $leakedSecrets = [
+            'a6156015-990f-4bbc-8fd1-309d51347407',  // CLIENT_ID
+            'Z0mo0XadAR44kCEAOw5XvECLA8QbFYeLyHnCM81b9bKC96LjzTDd80cItLZ2wzmO',  // CLIENT_SECRET
+            'VJJVkt467ltuXU__356XEtS',  // SECRET_PHRASE
+        ];
+
+        foreach ($leakedSecrets as $secret) {
+            $this->assertStringNotContainsString(
+                $secret,
+                $envExample,
+                ".env.example must not contain leaked secret: " . substr($secret, 0, 20) . "..."
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/BasicAuthSecretsTest.php b/erp24/tests/unit/config/BasicAuthSecretsTest.php
new file mode 100644 (file)
index 0000000..05576e3
--- /dev/null
@@ -0,0 +1,288 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for Basic Auth secrets migration
+ *
+ * Verifies that Basic Auth credentials are migrated to environment variables
+ * and no hardcoded passwords remain in the codebase.
+ *
+ * Covers:
+ * - erp24/modul/florist24/index.php (QR code generation)
+ * - erp24/api1_old/orders/get_orders.php (order links)
+ *
+ * @group config
+ * @group basic-auth
+ * @group secrets
+ */
+class BasicAuthSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // florist24/index.php tests
+    // ============================================================
+
+    /**
+     * Test florist24/index.php exists
+     */
+    public function testFlorist24IndexFileExists(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $this->assertFileExists($filePath, 'modul/florist24/index.php must exist');
+    }
+
+    /**
+     * Test florist24/index.php uses BASIC_AUTH_DEMO2_USER env
+     */
+    public function testFlorist24UsesDemo2UserEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]BASIC_AUTH_DEMO2_USER[\'"]\s*\)/',
+            $content,
+            'florist24/index.php must use getenv(\'BASIC_AUTH_DEMO2_USER\')'
+        );
+    }
+
+    /**
+     * Test florist24/index.php uses BASIC_AUTH_DEMO2_PASSWORD env
+     */
+    public function testFlorist24UsesDemo2PasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]BASIC_AUTH_DEMO2_PASSWORD[\'"]\s*\)/',
+            $content,
+            'florist24/index.php must use getenv(\'BASIC_AUTH_DEMO2_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test florist24/index.php has no hardcoded demo2 password
+     */
+    public function testFlorist24NoHardcodedDemo2Password(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked password
+        $this->assertStringNotContainsString(
+            'aG7hY9kR',
+            $content,
+            'florist24/index.php must not contain hardcoded demo2 password'
+        );
+    }
+
+    /**
+     * Test florist24/index.php has no hardcoded Basic Auth in URL
+     */
+    public function testFlorist24NoHardcodedBasicAuthInUrl(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/https:\/\/demo2:[a-zA-Z0-9]+@/',
+            $content,
+            'florist24/index.php must not contain hardcoded Basic Auth in URL'
+        );
+    }
+
+    /**
+     * Test florist24/index.php has PHP doc
+     */
+    public function testFlorist24HasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/modul/florist24/index.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'florist24/index.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // get_orders.php tests
+    // ============================================================
+
+    /**
+     * Test get_orders.php exists
+     */
+    public function testGetOrdersFileExists(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $this->assertFileExists($filePath, 'api1_old/orders/get_orders.php must exist');
+    }
+
+    /**
+     * Test get_orders.php uses BASIC_AUTH_KASSEAR_USER env
+     */
+    public function testGetOrdersUsesKassearUserEnv(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]BASIC_AUTH_KASSEAR_USER[\'"]\s*\)/',
+            $content,
+            'get_orders.php must use getenv(\'BASIC_AUTH_KASSEAR_USER\')'
+        );
+    }
+
+    /**
+     * Test get_orders.php uses BASIC_AUTH_KASSEAR_PASSWORD env
+     */
+    public function testGetOrdersUsesKassearPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]BASIC_AUTH_KASSEAR_PASSWORD[\'"]\s*\)/',
+            $content,
+            'get_orders.php must use getenv(\'BASIC_AUTH_KASSEAR_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test get_orders.php has no hardcoded kassear password
+     */
+    public function testGetOrdersNoHardcodedKassearPassword(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked password
+        $this->assertStringNotContainsString(
+            'mL9mN0pO7l',
+            $content,
+            'get_orders.php must not contain hardcoded kassear password'
+        );
+    }
+
+    /**
+     * Test get_orders.php has no hardcoded Basic Auth in URL
+     */
+    public function testGetOrdersNoHardcodedBasicAuthInUrl(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/https:\/\/kassear:[a-zA-Z0-9]+@/',
+            $content,
+            'get_orders.php must not contain hardcoded Basic Auth in URL'
+        );
+    }
+
+    /**
+     * Test get_orders.php has PHP doc
+     */
+    public function testGetOrdersHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/api1_old/orders/get_orders.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'get_orders.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests
+    // ============================================================
+
+    /**
+     * Test .env.example has BASIC_AUTH_KASSEAR_USER
+     */
+    public function testEnvExampleHasKassearUser(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'BASIC_AUTH_KASSEAR_USER=',
+            $envExample,
+            '.env.example must contain BASIC_AUTH_KASSEAR_USER'
+        );
+    }
+
+    /**
+     * Test .env.example has BASIC_AUTH_KASSEAR_PASSWORD
+     */
+    public function testEnvExampleHasKassearPassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'BASIC_AUTH_KASSEAR_PASSWORD=',
+            $envExample,
+            '.env.example must contain BASIC_AUTH_KASSEAR_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has BASIC_AUTH_DEMO2_USER
+     */
+    public function testEnvExampleHasDemo2User(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'BASIC_AUTH_DEMO2_USER=',
+            $envExample,
+            '.env.example must contain BASIC_AUTH_DEMO2_USER'
+        );
+    }
+
+    /**
+     * Test .env.example has BASIC_AUTH_DEMO2_PASSWORD
+     */
+    public function testEnvExampleHasDemo2Password(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'BASIC_AUTH_DEMO2_PASSWORD=',
+            $envExample,
+            '.env.example must contain BASIC_AUTH_DEMO2_PASSWORD'
+        );
+    }
+
+    /**
+     * Test no known leaked Basic Auth passwords in .env.example
+     */
+    public function testEnvExampleNoLeakedBasicAuthPasswords(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $leakedPasswords = [
+            'aG7hY9kR',     // demo2 password
+            'mL9mN0pO7l',  // kassear password
+        ];
+
+        foreach ($leakedPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $envExample,
+                ".env.example must not contain leaked password: {$password}"
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/BonusPlusSecretsTest.php b/erp24/tests/unit/config/BonusPlusSecretsTest.php
new file mode 100644 (file)
index 0000000..1e3c93a
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for BonusPlus bonusplus_api.php getenv() migration
+ *
+ * Verifies that bonusplus_api.php uses environment variables
+ * for API key credential (1 secret).
+ *
+ * Covers changes in:
+ * - erp24/modul/bonus/bonusplus_api.php
+ *
+ * ENV variables tested:
+ * - BONUSPLUS_API_KEY
+ *
+ * @group config
+ * @group bonusplus
+ * @group secrets
+ */
+class BonusPlusSecretsTest extends Unit
+{
+    private string $filePath;
+
+    protected function _before(): void
+    {
+        $this->filePath = dirname(__DIR__, 3) . '/modul/bonus/bonusplus_api.php';
+    }
+
+    /**
+     * Test bonusplus_api.php file exists
+     */
+    public function testFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->filePath,
+            'bonusplus_api.php must exist'
+        );
+    }
+
+    /**
+     * Test BONUSPLUS_API_KEY uses getenv
+     */
+    public function testApiKeyUsesEnv(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            "getenv('BONUSPLUS_API_KEY')",
+            $content,
+            'bonusplus_api.php must use BONUSPLUS_API_KEY from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded API key
+     */
+    public function testNoHardcodedApiKey(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that bonusPlusApiKey is not assigned a literal string (except getenv)
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$bonusPlusApiKey\s*=\s*[\'"][a-zA-Z0-9_-]{20,}[\'"];/',
+            $content,
+            'bonusplus_api.php must not have hardcoded API key'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback
+     */
+    public function testGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'BONUSPLUS_API_KEY'\s*\)\s*\?:\s*''/",
+            $content,
+            'BONUSPLUS_API_KEY must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test API key is used in Authorization header with base64
+     */
+    public function testApiKeyUsedInAuthorizationHeader(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that bonusPlusApiKey is passed to base64_encode for Authorization
+        $this->assertStringContainsString(
+            'base64_encode($bonusPlusApiKey)',
+            $content,
+            'API key must be base64 encoded for Authorization header'
+        );
+    }
+
+    /**
+     * Test Authorization header format
+     */
+    public function testAuthorizationHeaderFormat(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check proper Authorization: ApiKey format
+        $this->assertStringContainsString(
+            "'Authorization: ApiKey '",
+            $content,
+            'Must use Authorization: ApiKey header format'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+
+        $this->assertStringContainsString(
+            'BONUSPLUS_API_KEY',
+            $content,
+            'PHPDoc must mention BONUSPLUS_API_KEY'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/CloudPaymentsSecretsTest.php b/erp24/tests/unit/config/CloudPaymentsSecretsTest.php
new file mode 100644 (file)
index 0000000..f20da00
--- /dev/null
@@ -0,0 +1,289 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for CloudPayments files getenv() migration
+ *
+ * Verifies that CloudPayments files use environment variables
+ * for credentials (4 secrets across 3 files).
+ *
+ * Covers changes in:
+ * - erp24/inc/cloudpayments.php
+ * - erp24/modul/api/cloudpayments.php
+ * - erp24/modul/collation/cloudpayments.php
+ *
+ * ENV variables tested:
+ * - CLOUDPAYMENTS_PUBLIC_ID
+ * - CLOUDPAYMENTS_SECRET
+ * - CLOUDPAYMENTS_REGION_PUBLIC_ID
+ * - CLOUDPAYMENTS_REGION_SECRET
+ *
+ * @group config
+ * @group cloudpayments
+ * @group secrets
+ */
+class CloudPaymentsSecretsTest extends Unit
+{
+    private string $incPath;
+    private string $modulApiPath;
+    private string $modulCollationPath;
+
+    protected function _before(): void
+    {
+        $basePath = dirname(__DIR__, 3);
+        $this->incPath = $basePath . '/inc/cloudpayments.php';
+        $this->modulApiPath = $basePath . '/modul/api/cloudpayments.php';
+        $this->modulCollationPath = $basePath . '/modul/collation/cloudpayments.php';
+    }
+
+    /**
+     * Test inc/cloudpayments.php file exists
+     */
+    public function testIncFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->incPath,
+            'inc/cloudpayments.php must exist'
+        );
+    }
+
+    /**
+     * Test modul/api/cloudpayments.php file exists
+     */
+    public function testModulApiFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->modulApiPath,
+            'modul/api/cloudpayments.php must exist'
+        );
+    }
+
+    /**
+     * Test modul/collation/cloudpayments.php file exists
+     */
+    public function testModulCollationFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->modulCollationPath,
+            'modul/collation/cloudpayments.php must exist'
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php is valid PHP syntax
+     */
+    public function testIncIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->incPath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'inc/cloudpayments.php must have valid PHP syntax: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php uses CLOUDPAYMENTS_PUBLIC_ID from env
+     */
+    public function testIncUsesPublicIdEnv(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_PUBLIC_ID')",
+            $content,
+            'inc/cloudpayments.php must use CLOUDPAYMENTS_PUBLIC_ID from env'
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php uses CLOUDPAYMENTS_SECRET from env
+     */
+    public function testIncUsesSecretEnv(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_SECRET')",
+            $content,
+            'inc/cloudpayments.php must use CLOUDPAYMENTS_SECRET from env'
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php uses CLOUDPAYMENTS_REGION_PUBLIC_ID from env
+     */
+    public function testIncUsesRegionPublicIdEnv(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_REGION_PUBLIC_ID')",
+            $content,
+            'inc/cloudpayments.php must use CLOUDPAYMENTS_REGION_PUBLIC_ID from env'
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php uses CLOUDPAYMENTS_REGION_SECRET from env
+     */
+    public function testIncUsesRegionSecretEnv(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_REGION_SECRET')",
+            $content,
+            'inc/cloudpayments.php must use CLOUDPAYMENTS_REGION_SECRET from env'
+        );
+    }
+
+    /**
+     * Test inc/cloudpayments.php has no hardcoded public ID
+     */
+    public function testIncNoHardcodedPublicId(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        // Pattern: pk_XXXX CloudPayments public key
+        $this->assertDoesNotMatchRegularExpression(
+            '/pk_[a-f0-9]{30,}/',
+            $content,
+            'inc/cloudpayments.php must not contain hardcoded public ID'
+        );
+    }
+
+    /**
+     * Test modul/api/cloudpayments.php uses getenv
+     */
+    public function testModulApiUsesGetenv(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_PUBLIC_ID')",
+            $content,
+            'modul/api/cloudpayments.php must use CLOUDPAYMENTS_PUBLIC_ID from env'
+        );
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_SECRET')",
+            $content,
+            'modul/api/cloudpayments.php must use CLOUDPAYMENTS_SECRET from env'
+        );
+    }
+
+    /**
+     * Test modul/api/cloudpayments.php has no hardcoded public ID in JS
+     */
+    public function testModulApiNoHardcodedPublicIdInJs(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        // Check that publicId in JS widget doesn't have hardcoded value
+        // Old pattern: publicId: 'pk_XXXX',
+        $this->assertDoesNotMatchRegularExpression(
+            "/publicId:\s*'pk_[a-f0-9]{30,}'/",
+            $content,
+            'modul/api/cloudpayments.php must not have hardcoded publicId in JS widget'
+        );
+    }
+
+    /**
+     * Test modul/collation/cloudpayments.php uses getenv
+     */
+    public function testModulCollationUsesGetenv(): void
+    {
+        $content = file_get_contents($this->modulCollationPath);
+
+        $this->assertStringContainsString(
+            "getenv('CLOUDPAYMENTS_PUBLIC_ID')",
+            $content,
+            'modul/collation/cloudpayments.php must use CLOUDPAYMENTS_PUBLIC_ID from env'
+        );
+    }
+
+    /**
+     * Test modul/collation/cloudpayments.php uses PHP variable in JS
+     */
+    public function testModulCollationUsesPhpVarInJs(): void
+    {
+        $content = file_get_contents($this->modulCollationPath);
+
+        // Check that publicId uses PHP variable with htmlspecialchars
+        $this->assertStringContainsString(
+            'htmlspecialchars($cloudPaymentsPublicId)',
+            $content,
+            'modul/collation/cloudpayments.php must use PHP variable with htmlspecialchars in JS'
+        );
+    }
+
+    /**
+     * Test all files have empty string fallback
+     */
+    public function testGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'CLOUDPAYMENTS_PUBLIC_ID'\s*\)\s*\?:\s*''/",
+            $content,
+            'CLOUDPAYMENTS_PUBLIC_ID must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'CLOUDPAYMENTS_SECRET'\s*\)\s*\?:\s*''/",
+            $content,
+            'CLOUDPAYMENTS_SECRET must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists in all files
+     */
+    public function testPhpDocExists(): void
+    {
+        $incContent = file_get_contents($this->incPath);
+        $apiContent = file_get_contents($this->modulApiPath);
+        $collationContent = file_get_contents($this->modulCollationPath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $incContent,
+            'PHPDoc must exist in inc/cloudpayments.php'
+        );
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $apiContent,
+            'PHPDoc must exist in modul/api/cloudpayments.php'
+        );
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $collationContent,
+            'PHPDoc must exist in modul/collation/cloudpayments.php'
+        );
+    }
+
+    /**
+     * Test region credentials support in inc/cloudpayments.php
+     */
+    public function testRegionCredentialsSupport(): void
+    {
+        $content = file_get_contents($this->incPath);
+
+        // Check that region condition exists
+        $this->assertStringContainsString(
+            '$setka=="region"',
+            $content,
+            'inc/cloudpayments.php must support region credential switching'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/ConfigIncSecretsTest.php b/erp24/tests/unit/config/ConfigIncSecretsTest.php
new file mode 100644 (file)
index 0000000..f918638
--- /dev/null
@@ -0,0 +1,473 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for config.inc.php secrets migration
+ *
+ * Verifies that config.inc.php uses environment variables
+ * for database credentials instead of hardcoded values.
+ *
+ * Covers:
+ * - erp24/config.inc.php (MySQL main, CRM, counter)
+ * - erp24/inc/db_bz24.php
+ * - erp24/api1_old/cron/salebot_import_from_google.php
+ * - erp24/api1/views/cron/bonus-users-sale-update.php
+ *
+ * @group config
+ * @group mysql
+ * @group secrets
+ */
+class ConfigIncSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // config.inc.php tests
+    // ============================================================
+
+    /**
+     * Test config.inc.php exists
+     */
+    public function testConfigIncFileExists(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $this->assertFileExists($filePath, 'config.inc.php must exist');
+    }
+
+    /**
+     * Test config.inc.php uses MYSQL_HOST env
+     */
+    public function testConfigIncUsesMysqlHostEnv(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_HOST[\'"]\s*\)/',
+            $content,
+            'config.inc.php must use getenv(\'MYSQL_HOST\')'
+        );
+    }
+
+    /**
+     * Test config.inc.php uses MYSQL_PASSWORD env
+     */
+    public function testConfigIncUsesMysqlPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_PASSWORD[\'"]\s*\)/',
+            $content,
+            'config.inc.php must use getenv(\'MYSQL_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test config.inc.php uses MYSQL_CRM_PASSWORD env
+     */
+    public function testConfigIncUsesMysqlCrmPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_CRM_PASSWORD[\'"]\s*\)/',
+            $content,
+            'config.inc.php must use getenv(\'MYSQL_CRM_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test config.inc.php uses MYSQL_COUNTER_PASSWORD env
+     */
+    public function testConfigIncUsesMysqlCounterPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_COUNTER_PASSWORD[\'"]\s*\)/',
+            $content,
+            'config.inc.php must use getenv(\'MYSQL_COUNTER_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test config.inc.php has no hardcoded MySQL password
+     */
+    public function testConfigIncNoHardcodedMysqlPassword(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked password
+        $this->assertStringNotContainsString(
+            'JVJruro_Xdg456o3ir',
+            $content,
+            'config.inc.php must not contain hardcoded MySQL password'
+        );
+    }
+
+    /**
+     * Test config.inc.php has no hardcoded counter password
+     */
+    public function testConfigIncNoHardcodedCounterPassword(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked password
+        $this->assertStringNotContainsString(
+            '{W~UtN8b}Mm3',
+            $content,
+            'config.inc.php must not contain hardcoded counter password'
+        );
+    }
+
+    /**
+     * Test config.inc.php has no hardcoded pass_dell_shop
+     */
+    public function testConfigIncNoHardcodedPassDellShop(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/\[.pass_dell_shop.\]\s*=\s*["\']Olidoell341["\']/',
+            $content,
+            'config.inc.php must not contain hardcoded pass_dell_shop'
+        );
+    }
+
+    /**
+     * Test config.inc.php uses SHOP_DELETE_PASSWORD env
+     */
+    public function testConfigIncUsesShopDeletePasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]SHOP_DELETE_PASSWORD[\'"]\s*\)/',
+            $content,
+            'config.inc.php must use getenv(\'SHOP_DELETE_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test config.inc.php has PHP doc
+     */
+    public function testConfigIncHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/config.inc.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'config.inc.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // db_bz24.php tests
+    // ============================================================
+
+    /**
+     * Test db_bz24.php exists
+     */
+    public function testDbBz24FileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/db_bz24.php';
+        $this->assertFileExists($filePath, 'inc/db_bz24.php must exist');
+    }
+
+    /**
+     * Test db_bz24.php uses MYSQL_BZ24_PASSWORD env
+     */
+    public function testDbBz24UsesMysqlBz24PasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db_bz24.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_BZ24_PASSWORD[\'"]\s*\)/',
+            $content,
+            'db_bz24.php must use getenv(\'MYSQL_BZ24_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test db_bz24.php has no hardcoded password
+     */
+    public function testDbBz24NoHardcodedPassword(): void
+    {
+        $filePath = $this->basePath . '/inc/db_bz24.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringNotContainsString(
+            'JVJruro_Xdg456o3ir',
+            $content,
+            'db_bz24.php must not contain hardcoded password'
+        );
+    }
+
+    /**
+     * Test db_bz24.php has conditional DB init
+     */
+    public function testDbBz24HasConditionalInit(): void
+    {
+        $filePath = $this->basePath . '/inc/db_bz24.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/if\s*\(\s*!empty\s*\(\s*\$config\[.DB_USER.\]/',
+            $content,
+            'db_bz24.php must check for credentials before init'
+        );
+    }
+
+    /**
+     * Test db_bz24.php has PHP doc
+     */
+    public function testDbBz24HasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/db_bz24.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'db_bz24.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // salebot_import_from_google.php tests
+    // ============================================================
+
+    /**
+     * Test salebot_import_from_google.php exists
+     */
+    public function testSalebotImportFileExists(): void
+    {
+        $filePath = $this->basePath . '/api1_old/cron/salebot_import_from_google.php';
+        $this->assertFileExists($filePath, 'api1_old/cron/salebot_import_from_google.php must exist');
+    }
+
+    /**
+     * Test salebot_import uses SALEBOT_IMPORT_TOKEN env
+     */
+    public function testSalebotImportUsesTokenEnv(): void
+    {
+        $filePath = $this->basePath . '/api1_old/cron/salebot_import_from_google.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]SALEBOT_IMPORT_TOKEN[\'"]\s*\)/',
+            $content,
+            'salebot_import_from_google.php must use getenv(\'SALEBOT_IMPORT_TOKEN\')'
+        );
+    }
+
+    /**
+     * Test salebot_import has no hardcoded token
+     */
+    public function testSalebotImportNoHardcodedToken(): void
+    {
+        $filePath = $this->basePath . '/api1_old/cron/salebot_import_from_google.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringNotContainsString(
+            '1CjgpXfgkh1pXV3KR2H57G3VtHCffrp154up1t36',
+            $content,
+            'salebot_import_from_google.php must not contain hardcoded token'
+        );
+    }
+
+    /**
+     * Test salebot_import has PHP doc
+     */
+    public function testSalebotImportHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/api1_old/cron/salebot_import_from_google.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'salebot_import_from_google.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // bonus-users-sale-update.php tests
+    // ============================================================
+
+    /**
+     * Test bonus-users-sale-update.php exists
+     */
+    public function testBonusUsersUpdateFileExists(): void
+    {
+        $filePath = $this->basePath . '/api1/views/cron/bonus-users-sale-update.php';
+        $this->assertFileExists($filePath, 'api1/views/cron/bonus-users-sale-update.php must exist');
+    }
+
+    /**
+     * Test bonus-users-sale-update uses TOKEN_CLOUD env
+     */
+    public function testBonusUsersUpdateUsesTokenCloudEnv(): void
+    {
+        $filePath = $this->basePath . '/api1/views/cron/bonus-users-sale-update.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TOKEN_CLOUD[\'"]\s*\)/',
+            $content,
+            'bonus-users-sale-update.php must use getenv(\'TOKEN_CLOUD\')'
+        );
+    }
+
+    /**
+     * Test bonus-users-sale-update has no hardcoded token
+     */
+    public function testBonusUsersUpdateNoHardcodedToken(): void
+    {
+        $filePath = $this->basePath . '/api1/views/cron/bonus-users-sale-update.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringNotContainsString(
+            'iC04295J9HyD2H3GJF3btky',
+            $content,
+            'bonus-users-sale-update.php must not contain hardcoded token'
+        );
+    }
+
+    /**
+     * Test bonus-users-sale-update has PHP doc
+     */
+    public function testBonusUsersUpdateHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/api1/views/cron/bonus-users-sale-update.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'bonus-users-sale-update.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // api_text.php tests (documentation)
+    // ============================================================
+
+    /**
+     * Test api_text.php has no hardcoded salebot token in docs
+     */
+    public function testApiTextNoHardcodedSalebotToken(): void
+    {
+        $filePath = $this->basePath . '/modul/api/api_text.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringNotContainsString(
+            '1CjgpXfgkh1pXV3KR2H57G3VtHCffrp154up1t36',
+            $content,
+            'api_text.php must not contain hardcoded salebot token in documentation'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests
+    // ============================================================
+
+    /**
+     * Test .env.example has MYSQL_PASSWORD
+     */
+    public function testEnvExampleHasMysqlPassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_PASSWORD=',
+            $envExample,
+            '.env.example must contain MYSQL_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has MYSQL_COUNTER_PASSWORD
+     */
+    public function testEnvExampleHasMysqlCounterPassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_COUNTER_PASSWORD=',
+            $envExample,
+            '.env.example must contain MYSQL_COUNTER_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has MYSQL_BZ24_PASSWORD
+     */
+    public function testEnvExampleHasMysqlBz24Password(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_BZ24_PASSWORD=',
+            $envExample,
+            '.env.example must contain MYSQL_BZ24_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has SALEBOT_IMPORT_TOKEN
+     */
+    public function testEnvExampleHasSalebotImportToken(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'SALEBOT_IMPORT_TOKEN=',
+            $envExample,
+            '.env.example must contain SALEBOT_IMPORT_TOKEN'
+        );
+    }
+
+    /**
+     * Test no known leaked passwords in .env.example
+     */
+    public function testEnvExampleNoLeakedPasswords(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $leakedPasswords = [
+            'JVJruro_Xdg456o3ir',
+            '{W~UtN8b}Mm3',
+            'Olidoell341',
+            '1CjgpXfgkh1pXV3KR2H57G3VtHCffrp154up1t36',
+            'iC04295J9HyD2H3GJF3btky',
+        ];
+
+        foreach ($leakedPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $envExample,
+                ".env.example must not contain leaked password: {$password}"
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/CookieValidationKeyConfigTest.php b/erp24/tests/unit/config/CookieValidationKeyConfigTest.php
new file mode 100644 (file)
index 0000000..c76827c
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit tests for cookie validation key configuration
+ * Verifies that COOKIE_VALIDATION_KEY is loaded from environment
+ *
+ * Covers changes in:
+ * - erp24/config/web.php - cookieValidationKey from getenv('COOKIE_VALIDATION_KEY')
+ * - erp24/api2/config/api2.config.php - cookieValidationKey from getenv('COOKIE_VALIDATION_KEY_API2')
+ *
+ * @group config
+ * @group security
+ * @group acceptance
+ */
+class CookieValidationKeyConfigTest extends Unit
+{
+    /**
+     * Test that web.php uses COOKIE_VALIDATION_KEY from env
+     */
+    public function testWebConfigUsesCookieKeyFromEnv(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/web.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('web.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify cookieValidationKey uses getenv
+        $this->assertStringContainsString(
+            "getenv('COOKIE_VALIDATION_KEY')",
+            $content,
+            'web.php must use COOKIE_VALIDATION_KEY from env'
+        );
+
+        // Verify no hardcoded key
+        $this->assertStringNotContainsString(
+            'Z0uKu8AtuwGTVD_qX4inPOe1xq3FdWcV',
+            $content,
+            'web.php must not contain hardcoded cookie validation key'
+        );
+    }
+
+    /**
+     * Test that api2.config.php uses COOKIE_VALIDATION_KEY from env
+     */
+    public function testApi2ConfigUsesCookieKeyFromEnv(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/api2/config/api2.config.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('api2.config.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify cookieValidationKey uses getenv with fallback
+        $this->assertStringContainsString(
+            "getenv('COOKIE_VALIDATION_KEY_API2')",
+            $content,
+            'api2.config.php must check COOKIE_VALIDATION_KEY_API2'
+        );
+
+        // Verify fallback to main COOKIE_VALIDATION_KEY
+        $this->assertStringContainsString(
+            "getenv('COOKIE_VALIDATION_KEY')",
+            $content,
+            'api2.config.php must fallback to COOKIE_VALIDATION_KEY'
+        );
+
+        // Verify no hardcoded key
+        $this->assertStringNotContainsString(
+            'erp24_DLVFJRBvmttertrrt_key',
+            $content,
+            'api2.config.php must not contain hardcoded cookie validation key'
+        );
+    }
+
+    /**
+     * Test COOKIE_VALIDATION_KEY environment variable
+     *
+     * @group integration
+     */
+    public function testCookieValidationKeyEnvVar(): void
+    {
+        $key = getenv('COOKIE_VALIDATION_KEY');
+
+        if ($key === false) {
+            $this->markTestSkipped('COOKIE_VALIDATION_KEY env var not set');
+        }
+
+        $this->assertNotEmpty($key, 'COOKIE_VALIDATION_KEY must not be empty');
+        $this->assertGreaterThan(
+            10,
+            strlen($key),
+            'COOKIE_VALIDATION_KEY should be at least 10 characters for security'
+        );
+    }
+
+    /**
+     * Test that Yii app has cookie validation configured
+     */
+    public function testYiiAppHasCookieValidation(): void
+    {
+        if (!\Yii::$app || !\Yii::$app->has('request')) {
+            $this->markTestSkipped('Yii::$app->request not available');
+        }
+
+        $request = \Yii::$app->request;
+        $cookieKey = $request->cookieValidationKey ?? null;
+
+        $this->assertNotNull($cookieKey, 'Request must have cookieValidationKey');
+        $this->assertNotEmpty($cookieKey, 'cookieValidationKey must not be empty');
+    }
+
+    /**
+     * Test cookie validation key is not a placeholder in production
+     */
+    public function testCookieKeyNotPlaceholder(): void
+    {
+        $key = getenv('COOKIE_VALIDATION_KEY');
+
+        if ($key === false) {
+            $this->markTestSkipped('COOKIE_VALIDATION_KEY env var not set');
+        }
+
+        $appEnv = getenv('APP_ENV');
+
+        // Only check in production
+        if ($appEnv === 'production') {
+            $this->assertStringNotContainsString(
+                'dev_cookie_key',
+                $key,
+                'Production must not use development placeholder cookie key'
+            );
+        } else {
+            $this->assertTrue(true, 'Placeholder check skipped in non-production environment');
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/DatabaseConfigTest.php b/erp24/tests/unit/config/DatabaseConfigTest.php
new file mode 100644 (file)
index 0000000..7070c4b
--- /dev/null
@@ -0,0 +1,199 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+use yii\db\Connection;
+
+/**
+ * Unit tests for database connections
+ * Verifies correct configuration and connectivity
+ *
+ * NOTE: Tests that require actual database connection are marked as @group integration
+ * and will be skipped if DB is not available.
+ *
+ * @group config
+ * @group database
+ * @group acceptance
+ */
+class DatabaseConfigTest extends Unit
+{
+    /**
+     * Verifies main database component exists
+     * NOTE: Requires Yii::$app to be initialized with db component
+     */
+    public function testMainDatabaseComponentExists(): void
+    {
+        if (!\Yii::$app || !\Yii::$app->has('db')) {
+            $this->markTestSkipped('Yii::$app->db not initialized in test environment');
+        }
+
+        $this->assertNotNull(\Yii::$app->db);
+        $this->assertInstanceOf(Connection::class, \Yii::$app->db);
+    }
+
+    /**
+     * Verifies main database connection
+     * This is an integration test - requires actual database
+     *
+     * @group integration
+     */
+    public function testMainDatabaseConnection(): void
+    {
+        try {
+            $db = \Yii::$app->db;
+            $db->open();
+            $result = $db->createCommand('SELECT 1 as value')->queryOne();
+
+            $this->assertIsArray($result);
+            $this->assertEquals(1, $result['value']);
+
+            $db->close();
+        } catch (\Exception $e) {
+            $this->markTestSkipped('Database not available: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * Verifies database driver in DSN
+     */
+    public function testDatabaseDriverIsConfigured(): void
+    {
+        $dsn = \Yii::$app->db->dsn;
+
+        $isPostgreSQL = str_starts_with($dsn, 'pgsql:');
+        $isMySQL = str_starts_with($dsn, 'mysql:');
+
+        $this->assertTrue(
+            $isPostgreSQL || $isMySQL,
+            "DSN must start with 'pgsql:' or 'mysql:', got: $dsn"
+        );
+    }
+
+    /**
+     * Verifies database charset
+     */
+    public function testDatabaseCharsetIsUtf8(): void
+    {
+        $charset = \Yii::$app->db->charset;
+        $this->assertEquals('utf8', $charset, 'Database charset must be utf8');
+    }
+
+    /**
+     * Verifies DSN is formed from environment variables
+     * NOTE: Test config may use different DSN than production
+     */
+    public function testDatabaseUsesEnvVariables(): void
+    {
+        $dsn = \Yii::$app->db->dsn;
+
+        // Just verify DSN has valid format with host
+        // We can't check exact host match because test env uses different config
+        $this->assertMatchesRegularExpression(
+            '/^(pgsql|mysql):host=/',
+            $dsn,
+            'DSN must have valid format with host'
+        );
+
+        // Only check env var match if full .env is loaded (COOKIE_VALIDATION_KEY is Dotenv-only)
+        if (getenv('COOKIE_VALIDATION_KEY') !== false) {
+            $expectedHost = getenv('POSTGRES_HOSTNAME') ?: getenv('DB_HOST');
+            if ($expectedHost) {
+                $this->assertStringContainsString(
+                    $expectedHost,
+                    $dsn,
+                    'DSN must contain host from environment variable'
+                );
+            }
+        }
+    }
+
+    /**
+     * Verifies transaction support
+     * This is an integration test - requires actual database
+     *
+     * @group integration
+     */
+    public function testCanExecuteTransaction(): void
+    {
+        try {
+            $db = \Yii::$app->db;
+            $db->open();
+        } catch (\Exception $e) {
+            $this->markTestSkipped('Database not available: ' . $e->getMessage());
+        }
+
+        $transaction = $db->beginTransaction();
+
+        try {
+            $db->createCommand('SELECT 1')->execute();
+            $transaction->rollBack();
+            $this->assertTrue(true); // Transaction created and rolled back successfully
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            $this->fail('Transaction error: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * Verifies migrations table exists
+     * This is an integration test - requires actual database
+     *
+     * @group integration
+     */
+    public function testMigrationsTableExists(): void
+    {
+        try {
+            $db = \Yii::$app->db;
+            $db->open();
+            $tables = $db->getSchema()->getTableNames();
+            $this->assertContains('migration', $tables, 'Table migration must exist');
+        } catch (\Exception $e) {
+            $this->markTestSkipped('Database not available: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * Verifies remote database component exists
+     */
+    public function testRemoteDatabaseComponentExists(): void
+    {
+        $dbRemote = \Yii::$app->get('dbRemote', false);
+
+        if ($dbRemote === null) {
+            $this->markTestSkipped('Component dbRemote not configured');
+        }
+
+        $this->assertInstanceOf(Connection::class, $dbRemote);
+    }
+
+    /**
+     * Verifies remote database uses getenv()
+     */
+    public function testRemoteDatabaseUsesEnvVariables(): void
+    {
+        $dbRemote = \Yii::$app->get('dbRemote', false);
+
+        if ($dbRemote === null) {
+            $this->markTestSkipped('Component dbRemote not configured');
+        }
+
+        $expectedHost = getenv('DB_REMOTE_HOST');
+        if ($expectedHost) {
+            $this->assertStringContainsString(
+                $expectedHost,
+                $dbRemote->dsn,
+                'DSN dbRemote must contain host from DB_REMOTE_HOST'
+            );
+        }
+
+        $expectedUser = getenv('DB_REMOTE_USER');
+        if ($expectedUser) {
+            $this->assertEquals(
+                $expectedUser,
+                $dbRemote->username,
+                'Username dbRemote must match DB_REMOTE_USER'
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/DbRemoteConfigTest.php b/erp24/tests/unit/config/DbRemoteConfigTest.php
new file mode 100644 (file)
index 0000000..0c9ed02
--- /dev/null
@@ -0,0 +1,341 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit tests for dbRemote conditional configuration
+ * Verifies that dbRemote component is conditionally loaded based on DB_REMOTE_HOST env var
+ *
+ * Covers changes in:
+ * - erp24/config/web.php
+ * - erp24/config/console.php
+ * - erp24/config/dev.console.config.php
+ * - erp24/config/prod.console.config.php
+ *
+ * The dbRemote component should:
+ * - Only be registered if DB_REMOTE_HOST is set
+ * - Use env variables for all connection parameters
+ * - Be removed from config if DB_REMOTE_HOST is empty
+ *
+ * @group config
+ * @group database
+ * @group dbremote
+ * @group acceptance
+ */
+class DbRemoteConfigTest extends Unit
+{
+    /**
+     * Test that web.php has conditional dbRemote loading logic
+     */
+    public function testWebConfigHasConditionalDbRemote(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/web.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('web.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify conditional unset logic exists
+        $this->assertStringContainsString(
+            "if (!getenv('DB_REMOTE_HOST'))",
+            $content,
+            'web.php must have conditional check for DB_REMOTE_HOST'
+        );
+
+        $this->assertStringContainsString(
+            "unset(\$config['components']['dbRemote'])",
+            $content,
+            'web.php must unset dbRemote when DB_REMOTE_HOST is not set'
+        );
+    }
+
+    /**
+     * Test that console.php has conditional dbRemote loading logic
+     */
+    public function testConsoleConfigHasConditionalDbRemote(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/console.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('console.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify conditional unset logic exists
+        $this->assertStringContainsString(
+            "if (!getenv('DB_REMOTE_HOST'))",
+            $content,
+            'console.php must have conditional check for DB_REMOTE_HOST'
+        );
+
+        $this->assertStringContainsString(
+            "unset(\$config['components']['dbRemote'])",
+            $content,
+            'console.php must unset dbRemote when DB_REMOTE_HOST is not set'
+        );
+    }
+
+    /**
+     * Test that dev.console.config.php has conditional dbRemote loading logic
+     */
+    public function testDevConsoleConfigHasConditionalDbRemote(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/dev.console.config.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('dev.console.config.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify conditional unset logic exists
+        $this->assertStringContainsString(
+            "if (!getenv('DB_REMOTE_HOST'))",
+            $content,
+            'dev.console.config.php must have conditional check for DB_REMOTE_HOST'
+        );
+
+        $this->assertStringContainsString(
+            "unset(\$config['components']['dbRemote'])",
+            $content,
+            'dev.console.config.php must unset dbRemote when DB_REMOTE_HOST is not set'
+        );
+    }
+
+    /**
+     * Test that prod.console.config.php has conditional dbRemote loading logic
+     */
+    public function testProdConsoleConfigHasConditionalDbRemote(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/prod.console.config.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('prod.console.config.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify conditional unset logic exists
+        $this->assertStringContainsString(
+            "if (!getenv('DB_REMOTE_HOST'))",
+            $content,
+            'prod.console.config.php must have conditional check for DB_REMOTE_HOST'
+        );
+
+        $this->assertStringContainsString(
+            "unset(\$config['components']['dbRemote'])",
+            $content,
+            'prod.console.config.php must unset dbRemote when DB_REMOTE_HOST is not set'
+        );
+    }
+
+    /**
+     * Test that dbRemote DSN uses environment variables
+     */
+    public function testDbRemoteDsnUsesEnvVariables(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/web.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('web.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify DSN uses DB_REMOTE_HOST
+        $this->assertStringContainsString(
+            "getenv('DB_REMOTE_HOST')",
+            $content,
+            'dbRemote DSN must use DB_REMOTE_HOST env var'
+        );
+
+        // Verify DSN uses DB_REMOTE_PORT
+        $this->assertStringContainsString(
+            "getenv('DB_REMOTE_PORT')",
+            $content,
+            'dbRemote DSN must use DB_REMOTE_PORT env var'
+        );
+
+        // Verify DSN uses DB_REMOTE_SCHEMA
+        $this->assertStringContainsString(
+            "getenv('DB_REMOTE_SCHEMA')",
+            $content,
+            'dbRemote DSN must use DB_REMOTE_SCHEMA env var'
+        );
+    }
+
+    /**
+     * Test that dbRemote credentials use environment variables
+     */
+    public function testDbRemoteCredentialsUseEnvVariables(): void
+    {
+        $configPath = dirname(__DIR__, 3) . '/config/web.php';
+
+        if (!file_exists($configPath)) {
+            $this->markTestSkipped('web.php not found');
+        }
+
+        $content = file_get_contents($configPath);
+
+        // Verify username uses env var
+        $this->assertStringContainsString(
+            "getenv('DB_REMOTE_USER')",
+            $content,
+            'dbRemote username must use DB_REMOTE_USER env var'
+        );
+
+        // Verify password uses env var
+        $this->assertStringContainsString(
+            "getenv('DB_REMOTE_PASSWORD')",
+            $content,
+            'dbRemote password must use DB_REMOTE_PASSWORD env var'
+        );
+    }
+
+    /**
+     * Test that hardcoded credentials are removed
+     */
+    public function testNoHardcodedDbRemoteCredentials(): void
+    {
+        $configFiles = [
+            dirname(__DIR__, 3) . '/config/web.php',
+            dirname(__DIR__, 3) . '/config/console.php',
+            dirname(__DIR__, 3) . '/config/dev.console.config.php',
+            dirname(__DIR__, 3) . '/config/prod.console.config.php',
+        ];
+
+        $hardcodedValues = [
+            '89.111.174.11',      // Old hardcoded IP
+            'ERP24_user',         // Old hardcoded username
+            'HJG6rtrhqaweruit',   // Part of old hardcoded password
+        ];
+
+        foreach ($configFiles as $configPath) {
+            if (!file_exists($configPath)) {
+                continue;
+            }
+
+            $content = file_get_contents($configPath);
+            $filename = basename($configPath);
+
+            foreach ($hardcodedValues as $value) {
+                $this->assertStringNotContainsString(
+                    $value,
+                    $content,
+                    "$filename must not contain hardcoded value: $value"
+                );
+            }
+        }
+    }
+
+    /**
+     * Test dbRemote component behavior in Yii application
+     */
+    public function testDbRemoteComponentBehavior(): void
+    {
+        $dbRemoteHost = getenv('DB_REMOTE_HOST');
+
+        if ($dbRemoteHost === false || empty($dbRemoteHost)) {
+            // When DB_REMOTE_HOST is not set, dbRemote should not exist
+            if (\Yii::$app) {
+                $dbRemote = \Yii::$app->get('dbRemote', false);
+                $this->assertNull(
+                    $dbRemote,
+                    'dbRemote component should not exist when DB_REMOTE_HOST is not set'
+                );
+            } else {
+                $this->markTestSkipped('Yii::$app not available');
+            }
+        } else {
+            // When DB_REMOTE_HOST is set, dbRemote should exist
+            if (\Yii::$app) {
+                $dbRemote = \Yii::$app->get('dbRemote', false);
+                $this->assertNotNull(
+                    $dbRemote,
+                    'dbRemote component should exist when DB_REMOTE_HOST is set'
+                );
+            } else {
+                $this->markTestSkipped('Yii::$app not available');
+            }
+        }
+    }
+
+    /**
+     * Test DSN building logic for dbRemote
+     */
+    public function testDbRemoteDsnBuildingLogic(): void
+    {
+        $testCases = [
+            [
+                'host' => 'remote-db.example.com',
+                'port' => '3306',
+                'schema' => 'cms',
+                'expected_contains' => ['remote-db.example.com', '3306', 'cms'],
+            ],
+            [
+                'host' => '192.168.1.100',
+                'port' => '3307',
+                'schema' => 'remote_db',
+                'expected_contains' => ['192.168.1.100', '3307', 'remote_db'],
+            ],
+        ];
+
+        foreach ($testCases as $index => $case) {
+            $dsn = $this->buildDbRemoteDsn($case['host'], $case['port'], $case['schema']);
+
+            $this->assertStringStartsWith('mysql:host=', $dsn, "Case $index: DSN must start with mysql:host=");
+
+            foreach ($case['expected_contains'] as $expected) {
+                $this->assertStringContainsString(
+                    $expected,
+                    $dsn,
+                    "Case $index: DSN must contain $expected"
+                );
+            }
+        }
+    }
+
+    /**
+     * Test default port when not specified
+     */
+    public function testDbRemoteDefaultPort(): void
+    {
+        $dsn = $this->buildDbRemoteDsn('localhost', '', 'cms');
+
+        $this->assertStringContainsString(
+            'port=3306',
+            $dsn,
+            'Default port should be 3306 when not specified'
+        );
+    }
+
+    /**
+     * Test default schema when not specified
+     */
+    public function testDbRemoteDefaultSchema(): void
+    {
+        $dsn = $this->buildDbRemoteDsn('localhost', '3306', '');
+
+        $this->assertStringContainsString(
+            'dbname=cms',
+            $dsn,
+            'Default schema should be cms when not specified'
+        );
+    }
+
+    /**
+     * Helper method to build dbRemote DSN string
+     * Replicates the logic from config files
+     */
+    private function buildDbRemoteDsn(string $host, string $port, string $schema): string
+    {
+        return 'mysql:host=' . $host .
+               ';port=' . ($port ?: '3306') .
+               ';dbname=' . ($schema ?: 'cms');
+    }
+}
diff --git a/erp24/tests/unit/config/DbTestSecretsTest.php b/erp24/tests/unit/config/DbTestSecretsTest.php
new file mode 100644 (file)
index 0000000..b506607
--- /dev/null
@@ -0,0 +1,233 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for db-test.php secrets migration
+ *
+ * Verifies that test database credentials are migrated to environment variables
+ * and no hardcoded passwords remain in the test database configuration.
+ *
+ * Covers:
+ * - erp24/inc/db-test.php
+ *
+ * @group config
+ * @group database
+ * @group secrets
+ */
+class DbTestSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    /**
+     * Test db-test.php exists
+     */
+    public function testDbTestFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $this->assertFileExists($filePath, 'inc/db-test.php must exist');
+    }
+
+    /**
+     * Test db-test.php uses MYSQL_TEST_HOST env
+     */
+    public function testDbTestUsesMysqlTestHostEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_TEST_HOST[\'"]\s*\)/',
+            $content,
+            'db-test.php must use getenv(\'MYSQL_TEST_HOST\')'
+        );
+    }
+
+    /**
+     * Test db-test.php uses MYSQL_TEST_USER env
+     */
+    public function testDbTestUsesMysqlTestUserEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_TEST_USER[\'"]\s*\)/',
+            $content,
+            'db-test.php must use getenv(\'MYSQL_TEST_USER\')'
+        );
+    }
+
+    /**
+     * Test db-test.php uses MYSQL_TEST_PASSWORD env
+     */
+    public function testDbTestUsesMysqlTestPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_TEST_PASSWORD[\'"]\s*\)/',
+            $content,
+            'db-test.php must use getenv(\'MYSQL_TEST_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test db-test.php uses MYSQL_TEST_DB env
+     */
+    public function testDbTestUsesMysqlTestDbEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]MYSQL_TEST_DB[\'"]\s*\)/',
+            $content,
+            'db-test.php must use getenv(\'MYSQL_TEST_DB\')'
+        );
+    }
+
+    /**
+     * Test db-test.php has no hardcoded password
+     */
+    public function testDbTestNoHardcodedPassword(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked password
+        $this->assertStringNotContainsString(
+            'yX2hF4mO2omY7x',
+            $content,
+            'db-test.php must not contain hardcoded test password'
+        );
+    }
+
+    /**
+     * Test db-test.php has no hardcoded user
+     */
+    public function testDbTestNoHardcodedUser(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        // Should not have hardcoded DB_USER assignment with test username
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"]DB_USER[\'"]\s*=>\s*[\'"]erp24_api_test[\'"]/',
+            $content,
+            'db-test.php must not contain hardcoded test username'
+        );
+    }
+
+    /**
+     * Test db-test.php has conditional initialization
+     */
+    public function testDbTestHasConditionalInit(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/if\s*\(\s*!empty\s*\(\s*\$config2\[.DB_USER.\]/',
+            $content,
+            'db-test.php must check for credentials before init'
+        );
+    }
+
+    /**
+     * Test db-test.php has PHP doc
+     */
+    public function testDbTestHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/db-test.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'db-test.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests
+    // ============================================================
+
+    /**
+     * Test .env.example has MYSQL_TEST_HOST
+     */
+    public function testEnvExampleHasMysqlTestHost(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_TEST_HOST=',
+            $envExample,
+            '.env.example must contain MYSQL_TEST_HOST'
+        );
+    }
+
+    /**
+     * Test .env.example has MYSQL_TEST_USER
+     */
+    public function testEnvExampleHasMysqlTestUser(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_TEST_USER=',
+            $envExample,
+            '.env.example must contain MYSQL_TEST_USER'
+        );
+    }
+
+    /**
+     * Test .env.example has MYSQL_TEST_PASSWORD
+     */
+    public function testEnvExampleHasMysqlTestPassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_TEST_PASSWORD=',
+            $envExample,
+            '.env.example must contain MYSQL_TEST_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has MYSQL_TEST_DB
+     */
+    public function testEnvExampleHasMysqlTestDb(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'MYSQL_TEST_DB=',
+            $envExample,
+            '.env.example must contain MYSQL_TEST_DB'
+        );
+    }
+
+    /**
+     * Test no leaked test password in .env.example
+     */
+    public function testEnvExampleNoLeakedTestPassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringNotContainsString(
+            'yX2hF4mO2omY7x',
+            $envExample,
+            '.env.example must not contain leaked test password'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/DockerSecretsTest.php b/erp24/tests/unit/config/DockerSecretsTest.php
new file mode 100644 (file)
index 0000000..9fe9645
--- /dev/null
@@ -0,0 +1,303 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for Docker configuration secrets
+ *
+ * Verifies that docker-compose.yml and related Docker files
+ * use environment variable substitution instead of hardcoded secrets.
+ *
+ * NOTE: These tests verify files outside the /www mount point.
+ * When running inside Docker container, tests for docker-compose.yml
+ * and docker/php/*.env are skipped because those files are not mounted.
+ *
+ * Covers:
+ * - docker-compose.yml
+ * - docker/php/dev.php.env
+ *
+ * @group config
+ * @group docker
+ * @group secrets
+ */
+class DockerSecretsTest extends Unit
+{
+    private string $projectRoot;
+    private bool $runningInDocker;
+
+    protected function _before(): void
+    {
+        // Detect if running in Docker container
+        $this->runningInDocker = file_exists('/.dockerenv') ||
+                                  is_dir('/www/vendor') ||
+                                  (getenv('DOCKER_CONTAINER') !== false);
+
+        // When running in Docker, erp24 is mounted at /www
+        // but docker-compose.yml is in the parent directory (not mounted)
+        if ($this->runningInDocker) {
+            // Try to find project root by going up from /www
+            $this->projectRoot = '/www/..'; // Parent of /www
+        } else {
+            $this->projectRoot = dirname(__DIR__, 4);
+        }
+    }
+
+    /**
+     * Helper to get file content, skipping test if file not accessible
+     */
+    private function getFileContentOrSkip(string $filePath, string $skipReason): string
+    {
+        if (!file_exists($filePath)) {
+            $this->markTestSkipped(
+                "Skipped: {$skipReason}. File not accessible in Docker container."
+            );
+        }
+        return file_get_contents($filePath);
+    }
+
+    // ============================================================
+    // docker-compose.yml tests
+    // ============================================================
+
+    /**
+     * Test docker-compose.yml exists
+     */
+    public function testDockerComposeExists(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+
+        if ($this->runningInDocker && !file_exists($filePath)) {
+            $this->markTestSkipped('docker-compose.yml is outside Docker mount. Run tests on host.');
+        }
+
+        $this->assertFileExists($filePath, 'docker-compose.yml must exist');
+    }
+
+    /**
+     * Test RabbitMQ user uses env substitution
+     */
+    public function testRabbitMqUserUsesEnv(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+        $content = $this->getFileContentOrSkip($filePath, 'docker-compose.yml');
+
+        $this->assertMatchesRegularExpression(
+            '/RABBITMQ_DEFAULT_USER\s*:\s*\$\{RABBIT_USER/',
+            $content,
+            'RABBITMQ_DEFAULT_USER must use ${RABBIT_USER} substitution'
+        );
+    }
+
+    /**
+     * Test RabbitMQ password uses env substitution
+     */
+    public function testRabbitMqPasswordUsesEnv(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+        $content = $this->getFileContentOrSkip($filePath, 'docker-compose.yml');
+
+        $this->assertMatchesRegularExpression(
+            '/RABBITMQ_DEFAULT_PASS\s*:\s*\$\{RABBIT_PASSWORD/',
+            $content,
+            'RABBITMQ_DEFAULT_PASS must use ${RABBIT_PASSWORD} substitution'
+        );
+    }
+
+    /**
+     * Test no hardcoded RabbitMQ password
+     */
+    public function testNoHardcodedRabbitMqPassword(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+        $content = $this->getFileContentOrSkip($filePath, 'docker-compose.yml');
+
+        // Should not contain a hardcoded password like: RABBITMQ_DEFAULT_PASS: somepassword
+        // But allow: RABBITMQ_DEFAULT_PASS: ${RABBIT_PASSWORD:-default}
+        $this->assertDoesNotMatchRegularExpression(
+            '/RABBITMQ_DEFAULT_PASS\s*:\s*[^$\s\n][^\n]*(?<!\})$/m',
+            $content,
+            'docker-compose.yml must not contain hardcoded RabbitMQ password'
+        );
+    }
+
+    /**
+     * Test RabbitMQ has default fallback values
+     */
+    public function testRabbitMqHasDefaultFallbacks(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+        $content = $this->getFileContentOrSkip($filePath, 'docker-compose.yml');
+
+        // Check for default value syntax: ${VAR:-default}
+        $this->assertMatchesRegularExpression(
+            '/\$\{RABBIT_USER:-[^}]+\}/',
+            $content,
+            'RABBIT_USER must have default fallback value'
+        );
+
+        $this->assertMatchesRegularExpression(
+            '/\$\{RABBIT_PASSWORD:-[^}]+\}/',
+            $content,
+            'RABBIT_PASSWORD must have default fallback value'
+        );
+    }
+
+    // ============================================================
+    // docker/php/dev.php.env tests
+    // ============================================================
+
+    /**
+     * Test dev.php.env exists
+     */
+    public function testDevPhpEnvExists(): void
+    {
+        $filePath = $this->projectRoot . '/docker/php/dev.php.env';
+
+        if ($this->runningInDocker && !file_exists($filePath)) {
+            $this->markTestSkipped('docker/php/dev.php.env is outside Docker mount. Run tests on host.');
+        }
+
+        $this->assertFileExists($filePath, 'docker/php/dev.php.env must exist');
+    }
+
+    /**
+     * Test dev.php.env has no hardcoded DB password
+     */
+    public function testDevPhpEnvNoHardcodedDbPassword(): void
+    {
+        $filePath = $this->projectRoot . '/docker/php/dev.php.env';
+        $content = $this->getFileContentOrSkip($filePath, 'docker/php/dev.php.env');
+
+        // Should not contain actual password, only placeholder or env reference
+        $this->assertDoesNotMatchRegularExpression(
+            '/DB_PASSWORD\s*=\s*[A-Za-z0-9_]{8,}(?<!\})$/',
+            $content,
+            'dev.php.env must not contain hardcoded DB password'
+        );
+    }
+
+    /**
+     * Test dev.php.env uses env substitution for password
+     */
+    public function testDevPhpEnvUsesEnvSubstitution(): void
+    {
+        $filePath = $this->projectRoot . '/docker/php/dev.php.env';
+        $content = $this->getFileContentOrSkip($filePath, 'docker/php/dev.php.env');
+
+        // Should use ${DB_PASSWORD:-...} or placeholder
+        $this->assertMatchesRegularExpression(
+            '/DB_PASSWORD\s*=\s*\$\{DB_PASSWORD|DB_PASSWORD\s*=\s*dev_password/',
+            $content,
+            'dev.php.env must use env substitution or placeholder for DB_PASSWORD'
+        );
+    }
+
+    /**
+     * Test dev.php.env has warning comment
+     */
+    public function testDevPhpEnvHasWarningComment(): void
+    {
+        $filePath = $this->projectRoot . '/docker/php/dev.php.env';
+        $content = $this->getFileContentOrSkip($filePath, 'docker/php/dev.php.env');
+
+        $this->assertStringContainsString(
+            'ВАЖНО',
+            $content,
+            'dev.php.env must have warning comment'
+        );
+    }
+
+    // ============================================================
+    // .gitignore tests for docker secrets
+    // ============================================================
+
+    /**
+     * Test .gitignore excludes docker env files
+     */
+    public function testGitignoreExcludesDockerEnv(): void
+    {
+        $filePath = $this->projectRoot . '/.gitignore';
+        $content = $this->getFileContentOrSkip($filePath, '.gitignore');
+
+        $this->assertMatchesRegularExpression(
+            '/docker\/php\/\*\.env/',
+            $content,
+            '.gitignore must exclude docker/php/*.env files'
+        );
+    }
+
+    /**
+     * Test .gitignore keeps .env.example files
+     */
+    public function testGitignoreKeepsEnvExample(): void
+    {
+        $filePath = $this->projectRoot . '/.gitignore';
+        $content = $this->getFileContentOrSkip($filePath, '.gitignore');
+
+        $this->assertMatchesRegularExpression(
+            '/!\*\.env\.example|!docker\/php\/\*\.env\.example/',
+            $content,
+            '.gitignore must keep *.env.example files'
+        );
+    }
+
+    // ============================================================
+    // Security pattern tests
+    // ============================================================
+
+    /**
+     * Test no known leaked passwords in docker files
+     */
+    public function testNoKnownLeakedPasswords(): void
+    {
+        $files = [
+            $this->projectRoot . '/docker-compose.yml',
+            $this->projectRoot . '/docker/php/dev.php.env',
+        ];
+
+        // Known leaked passwords that should be rotated
+        $leakedPasswords = [
+            '3qqHK2MRgGgxUdVT61',  // RabbitMQ
+            'JVJruro_Xdg456o3ir',  // DB password
+        ];
+
+        $testedAny = false;
+        foreach ($files as $file) {
+            if (!file_exists($file)) {
+                continue;
+            }
+            $testedAny = true;
+            $content = file_get_contents($file);
+
+            foreach ($leakedPasswords as $password) {
+                $this->assertStringNotContainsString(
+                    $password,
+                    $content,
+                    basename($file) . " must not contain leaked password: {$password}"
+                );
+            }
+        }
+
+        if (!$testedAny && $this->runningInDocker) {
+            $this->markTestSkipped('Docker files not accessible. Run tests on host.');
+        }
+    }
+
+    /**
+     * Test docker-compose.yml env_file references
+     */
+    public function testDockerComposeEnvFileReferences(): void
+    {
+        $filePath = $this->projectRoot . '/docker-compose.yml';
+        $content = $this->getFileContentOrSkip($filePath, 'docker-compose.yml');
+
+        // PHP service should reference env file
+        $this->assertMatchesRegularExpression(
+            '/env_file\s*:.*docker\/php\/.*\.env/s',
+            $content,
+            'docker-compose.yml should reference docker/php/*.env in env_file'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/DocumentationSecretsTest.php b/erp24/tests/unit/config/DocumentationSecretsTest.php
new file mode 100644 (file)
index 0000000..c30ff6b
--- /dev/null
@@ -0,0 +1,191 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for documentation secrets cleanup
+ *
+ * Verifies that documentation files do not contain hardcoded secrets
+ * such as passwords, tokens, or API keys.
+ *
+ * Covers:
+ * - erp24/docs/api/api2/DEPENDENCIES.md
+ * - erp24/docs/api/api2/ARCHITECTURE.md
+ *
+ * @group config
+ * @group documentation
+ * @group secrets
+ */
+class DocumentationSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // DEPENDENCIES.md tests
+    // ============================================================
+
+    /**
+     * Test DEPENDENCIES.md exists
+     */
+    public function testDependenciesMdExists(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/DEPENDENCIES.md';
+        $this->assertFileExists($filePath, 'docs/api/api2/DEPENDENCIES.md must exist');
+    }
+
+    /**
+     * Test DEPENDENCIES.md has no hardcoded RabbitMQ password
+     */
+    public function testDependenciesMdNoHardcodedRabbitPassword(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/DEPENDENCIES.md';
+        $content = file_get_contents($filePath);
+
+        // Known leaked RabbitMQ password
+        $this->assertStringNotContainsString(
+            '3qqHK2MRgGgxUdVT61',
+            $content,
+            'DEPENDENCIES.md must not contain hardcoded RabbitMQ password'
+        );
+    }
+
+    /**
+     * Test DEPENDENCIES.md uses placeholder for RabbitMQ credentials
+     */
+    public function testDependenciesMdUsesRabbitPlaceholder(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/DEPENDENCIES.md';
+        $content = file_get_contents($filePath);
+
+        // Should use placeholder variables
+        $this->assertMatchesRegularExpression(
+            '/\$\{RABBIT_USER\}|\$\{RABBIT_PASSWORD\}/',
+            $content,
+            'DEPENDENCIES.md should use ${RABBIT_*} placeholders'
+        );
+    }
+
+    // ============================================================
+    // ARCHITECTURE.md tests
+    // ============================================================
+
+    /**
+     * Test ARCHITECTURE.md exists
+     */
+    public function testArchitectureMdExists(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/ARCHITECTURE.md';
+        $this->assertFileExists($filePath, 'docs/api/api2/ARCHITECTURE.md must exist');
+    }
+
+    /**
+     * Test ARCHITECTURE.md has no hardcoded RabbitMQ password
+     */
+    public function testArchitectureMdNoHardcodedRabbitPassword(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/ARCHITECTURE.md';
+        $content = file_get_contents($filePath);
+
+        // Known leaked RabbitMQ password
+        $this->assertStringNotContainsString(
+            '3qqHK2MRgGgxUdVT61',
+            $content,
+            'ARCHITECTURE.md must not contain hardcoded RabbitMQ password'
+        );
+    }
+
+    /**
+     * Test ARCHITECTURE.md uses placeholder for RabbitMQ credentials
+     */
+    public function testArchitectureMdUsesRabbitPlaceholder(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/ARCHITECTURE.md';
+        $content = file_get_contents($filePath);
+
+        // Should use placeholder variables
+        $this->assertMatchesRegularExpression(
+            '/\$\{RABBIT_USER\}|\$\{RABBIT_PASSWORD\}/',
+            $content,
+            'ARCHITECTURE.md should use ${RABBIT_*} placeholders'
+        );
+    }
+
+    /**
+     * Test ARCHITECTURE.md mentions .env for secrets
+     */
+    public function testArchitectureMdMentionsEnvForSecrets(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/ARCHITECTURE.md';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            '.env',
+            $content,
+            'ARCHITECTURE.md should mention .env for credentials'
+        );
+    }
+
+    // ============================================================
+    // General documentation security tests
+    // ============================================================
+
+    /**
+     * Test no known leaked passwords in documentation
+     */
+    public function testNoLeakedPasswordsInDocs(): void
+    {
+        $docFiles = [
+            $this->basePath . '/docs/api/api2/DEPENDENCIES.md',
+            $this->basePath . '/docs/api/api2/ARCHITECTURE.md',
+        ];
+
+        $leakedPasswords = [
+            '3qqHK2MRgGgxUdVT61',      // RabbitMQ
+            'JVJruro_Xdg456o3ir',      // MySQL
+            'iC04295J9HyD2H3GJF3btky', // TOKEN_CLOUD
+            'getJH6GFi4tpU84YVPW9M',   // API_TOKEN
+            'aG7hY9kR',                // demo2 basic auth
+            'mL9mN0pO7l',              // kassear basic auth
+            'yX2hF4mO2omY7x',          // test DB password
+        ];
+
+        foreach ($docFiles as $filePath) {
+            if (!file_exists($filePath)) {
+                continue;
+            }
+
+            $content = file_get_contents($filePath);
+
+            foreach ($leakedPasswords as $password) {
+                $this->assertStringNotContainsString(
+                    $password,
+                    $content,
+                    basename($filePath) . " must not contain leaked password: {$password}"
+                );
+            }
+        }
+    }
+
+    /**
+     * Test documentation files recommend using environment variables
+     */
+    public function testDocsRecommendEnvVariables(): void
+    {
+        $filePath = $this->basePath . '/docs/api/api2/ARCHITECTURE.md';
+        $content = file_get_contents($filePath);
+
+        // Should have a note about environment variables
+        $this->assertMatchesRegularExpression(
+            '/ВАЖНО|env|\.env|переменных окружения/i',
+            $content,
+            'ARCHITECTURE.md should recommend using environment variables'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/DomruCamerasSecretsTest.php b/erp24/tests/unit/config/DomruCamerasSecretsTest.php
new file mode 100644 (file)
index 0000000..d111346
--- /dev/null
@@ -0,0 +1,395 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for Дом.ру camera credentials getenv() migration
+ *
+ * Verifies that all domru camera files use environment variables
+ * for Login/Password credentials.
+ *
+ * Covers changes in:
+ * - erp24/api1/views/cron/domru-cams.php (6 cameras)
+ * - erp24/modul/api/domru_cams.php (2 cameras)
+ * - erp24/api1_old/cron/domru_cams.php (5 cameras)
+ *
+ * ENV variables tested:
+ * - CAMERA_1_LOGIN through CAMERA_6_LOGIN
+ * - CAMERA_1_PASSWORD through CAMERA_6_PASSWORD
+ *
+ * @group config
+ * @group cameras
+ * @group secrets
+ */
+class DomruCamerasSecretsTest extends Unit
+{
+    private string $api1CronPath;
+    private string $modulApiPath;
+    private string $api1OldPath;
+
+    /**
+     * @var array Known hardcoded camera logins that should NOT be present
+     */
+    private array $forbiddenLogins = [
+        'mochage-8r-136',
+        'kuznzx-am-136',
+        'lazava-d8-136',
+        'ipbelo-c8-6p',
+        'ipbelo-n2-ci',
+        'mochal-3d-3j',
+        'tmp-4125315',
+    ];
+
+    /**
+     * @var array Known hardcoded camera passwords that should NOT be present
+     */
+    private array $forbiddenPasswords = [
+        'fjtq8z3u',
+        '8e4ma237',
+        'L9ouuSk5',
+        '35ghRtYWqq',
+        '8h09h42q38',
+        'pq1Wm4rS',
+        '5gp3znn0',
+    ];
+
+    protected function _before(): void
+    {
+        $basePath = dirname(__DIR__, 3);
+        $this->api1CronPath = $basePath . '/api1/views/cron/domru-cams.php';
+        $this->modulApiPath = $basePath . '/modul/api/domru_cams.php';
+        $this->api1OldPath = $basePath . '/api1_old/cron/domru_cams.php';
+    }
+
+    // === api1/views/cron/domru-cams.php tests ===
+
+    /**
+     * Test api1 cron file exists
+     */
+    public function testApi1CronFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->api1CronPath,
+            'api1/views/cron/domru-cams.php must exist'
+        );
+    }
+
+    /**
+     * Test api1 cron uses all 6 camera credentials from env
+     */
+    public function testApi1CronUsesAllCameraCredentials(): void
+    {
+        $content = file_get_contents($this->api1CronPath);
+
+        for ($i = 1; $i <= 6; $i++) {
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_LOGIN')",
+                $content,
+                "domru-cams.php must use CAMERA_{$i}_LOGIN from env"
+            );
+
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_PASSWORD')",
+                $content,
+                "domru-cams.php must use CAMERA_{$i}_PASSWORD from env"
+            );
+        }
+    }
+
+    /**
+     * Test api1 cron has no hardcoded logins
+     */
+    public function testApi1CronNoHardcodedLogins(): void
+    {
+        $content = file_get_contents($this->api1CronPath);
+
+        foreach ($this->forbiddenLogins as $login) {
+            $this->assertStringNotContainsString(
+                $login,
+                $content,
+                "domru-cams.php must not contain hardcoded login: $login"
+            );
+        }
+    }
+
+    /**
+     * Test api1 cron has no hardcoded passwords
+     */
+    public function testApi1CronNoHardcodedPasswords(): void
+    {
+        $content = file_get_contents($this->api1CronPath);
+
+        foreach ($this->forbiddenPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $content,
+                "domru-cams.php must not contain hardcoded password: $password"
+            );
+        }
+    }
+
+    /**
+     * Test api1 cron PHPDoc exists
+     */
+    public function testApi1CronPhpDocExists(): void
+    {
+        $content = file_get_contents($this->api1CronPath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+
+        $this->assertStringContainsString(
+            'CAMERA_1_LOGIN',
+            $content,
+            'PHPDoc must mention CAMERA_1_LOGIN'
+        );
+    }
+
+    /**
+     * Test api1 cron getenv has empty fallback
+     */
+    public function testApi1CronGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->api1CronPath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'CAMERA_1_LOGIN'\s*\)\s*\?:\s*''/",
+            $content,
+            'CAMERA_1_LOGIN must have empty string fallback'
+        );
+    }
+
+    // === modul/api/domru_cams.php tests ===
+
+    /**
+     * Test modul api file exists
+     */
+    public function testModulApiFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->modulApiPath,
+            'modul/api/domru_cams.php must exist'
+        );
+    }
+
+    /**
+     * Test modul api uses camera credentials from env
+     */
+    public function testModulApiUsesCameraCredentials(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        // This file uses cameras 1 and 2
+        for ($i = 1; $i <= 2; $i++) {
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_LOGIN')",
+                $content,
+                "domru_cams.php must use CAMERA_{$i}_LOGIN from env"
+            );
+
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_PASSWORD')",
+                $content,
+                "domru_cams.php must use CAMERA_{$i}_PASSWORD from env"
+            );
+        }
+    }
+
+    /**
+     * Test modul api has no hardcoded logins
+     */
+    public function testModulApiNoHardcodedLogins(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        foreach ($this->forbiddenLogins as $login) {
+            $this->assertStringNotContainsString(
+                $login,
+                $content,
+                "domru_cams.php must not contain hardcoded login: $login"
+            );
+        }
+    }
+
+    /**
+     * Test modul api has no hardcoded passwords
+     */
+    public function testModulApiNoHardcodedPasswords(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        foreach ($this->forbiddenPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $content,
+                "domru_cams.php must not contain hardcoded password: $password"
+            );
+        }
+    }
+
+    /**
+     * Test modul api PHPDoc exists
+     */
+    public function testModulApiPhpDocExists(): void
+    {
+        $content = file_get_contents($this->modulApiPath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+    }
+
+    // === api1_old/cron/domru_cams.php tests ===
+
+    /**
+     * Test api1_old file exists
+     */
+    public function testApi1OldFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->api1OldPath,
+            'api1_old/cron/domru_cams.php must exist'
+        );
+    }
+
+    /**
+     * Test api1_old uses camera credentials from env
+     */
+    public function testApi1OldUsesCameraCredentials(): void
+    {
+        $content = file_get_contents($this->api1OldPath);
+
+        // This file uses cameras 1-5
+        for ($i = 1; $i <= 5; $i++) {
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_LOGIN')",
+                $content,
+                "api1_old/domru_cams.php must use CAMERA_{$i}_LOGIN from env"
+            );
+
+            $this->assertStringContainsString(
+                "getenv('CAMERA_{$i}_PASSWORD')",
+                $content,
+                "api1_old/domru_cams.php must use CAMERA_{$i}_PASSWORD from env"
+            );
+        }
+    }
+
+    /**
+     * Test api1_old has no hardcoded logins
+     */
+    public function testApi1OldNoHardcodedLogins(): void
+    {
+        $content = file_get_contents($this->api1OldPath);
+
+        foreach ($this->forbiddenLogins as $login) {
+            $this->assertStringNotContainsString(
+                $login,
+                $content,
+                "api1_old/domru_cams.php must not contain hardcoded login: $login"
+            );
+        }
+    }
+
+    /**
+     * Test api1_old has no hardcoded passwords
+     */
+    public function testApi1OldNoHardcodedPasswords(): void
+    {
+        $content = file_get_contents($this->api1OldPath);
+
+        foreach ($this->forbiddenPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $content,
+                "api1_old/domru_cams.php must not contain hardcoded password: $password"
+            );
+        }
+    }
+
+    /**
+     * Test api1_old PHPDoc exists
+     */
+    public function testApi1OldPhpDocExists(): void
+    {
+        $content = file_get_contents($this->api1OldPath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+    }
+
+    /**
+     * Test api1_old no hardcoded API token in URL comment
+     */
+    public function testApi1OldNoHardcodedApiTokenInUrl(): void
+    {
+        $content = file_get_contents($this->api1OldPath);
+
+        // The URL in comment should not contain the actual token
+        $this->assertStringNotContainsString(
+            '1CjgpXfgkh1pXV3KR2H57G3VtHCffrp154up1t36',
+            $content,
+            'api1_old/domru_cams.php must not contain hardcoded API token'
+        );
+    }
+
+    // === Cross-file consistency tests ===
+
+    /**
+     * Test all files use consistent getenv pattern
+     */
+    public function testAllFilesUseConsistentPattern(): void
+    {
+        $files = [
+            $this->api1CronPath,
+            $this->modulApiPath,
+            $this->api1OldPath,
+        ];
+
+        foreach ($files as $file) {
+            $content = file_get_contents($file);
+
+            // All should use the pattern: getenv('CAMERA_X_LOGIN') ?: ''
+            $this->assertMatchesRegularExpression(
+                "/getenv\s*\(\s*'CAMERA_\d+_LOGIN'\s*\)\s*\?:\s*''/",
+                $content,
+                basename($file) . ' must use getenv with empty fallback pattern'
+            );
+        }
+    }
+
+    /**
+     * Test .env.example has all camera variables
+     */
+    public function testEnvExampleHasAllCameraVariables(): void
+    {
+        $envExample = dirname(__DIR__, 3) . '/.env.example';
+        $this->assertFileExists($envExample);
+
+        $content = file_get_contents($envExample);
+
+        for ($i = 1; $i <= 6; $i++) {
+            $this->assertStringContainsString(
+                "CAMERA_{$i}_LOGIN=",
+                $content,
+                ".env.example must have CAMERA_{$i}_LOGIN"
+            );
+
+            $this->assertStringContainsString(
+                "CAMERA_{$i}_PASSWORD=",
+                $content,
+                ".env.example must have CAMERA_{$i}_PASSWORD"
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/EnvConfigurationTest.php b/erp24/tests/unit/config/EnvConfigurationTest.php
new file mode 100644 (file)
index 0000000..f9a0285
--- /dev/null
@@ -0,0 +1,237 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit tests for environment variables
+ * Verifies loading and availability of .env variables
+ *
+ * NOTE: These tests check the presence of env vars in the test environment.
+ * Some tests are skipped if vars are not set (optional in test env).
+ *
+ * @group config
+ * @group env
+ * @group acceptance
+ */
+class EnvConfigurationTest extends Unit
+{
+    /**
+     * @var array List of required environment variables
+     */
+    private array $requiredEnvVars = [
+        'APP_ENV',
+        'POSTGRES_PASSWORD',
+        'RABBIT_USER',
+        'RABBIT_PASSWORD',
+        'TELEGRAM_BOT_TOKEN',
+        'COOKIE_VALIDATION_KEY',
+    ];
+
+    /**
+     * Verifies that all required environment variables are set
+     * IMPORTANT: This test requires full .env to be loaded via Dotenv
+     *
+     * @group integration
+     */
+    public function testRequiredEnvVariablesExist(): void
+    {
+        // Skip if .env not fully loaded (check for a variable only set by Dotenv)
+        // RABBIT_USER is only in .env file, not in Docker compose
+        if (getenv('RABBIT_USER') === false || getenv('COOKIE_VALIDATION_KEY') === false) {
+            $this->markTestSkipped('.env file not fully loaded (Dotenv not initialized)');
+        }
+
+        foreach ($this->requiredEnvVars as $var) {
+            $value = getenv($var);
+            $this->assertNotFalse($value, "Required variable $var is not set");
+            $this->assertNotEmpty($value, "Required variable $var is empty");
+        }
+    }
+
+    /**
+     * Verifies PostgreSQL environment variables
+     */
+    public function testPostgresEnvVariables(): void
+    {
+        $host = getenv('POSTGRES_HOSTNAME');
+        $port = getenv('POSTGRES_PORT');
+        $schema = getenv('POSTGRES_SCHEMA');
+        $user = getenv('POSTGRES_USER');
+        $password = getenv('POSTGRES_PASSWORD');
+
+        // Only check if at least one var is set (indicates .env loaded)
+        if ($host === false && $port === false && $schema === false) {
+            $this->markTestSkipped('PostgreSQL env vars not loaded');
+        }
+
+        if ($host !== false) {
+            $this->assertNotEmpty($host, 'POSTGRES_HOSTNAME should not be empty');
+        }
+        if ($port !== false) {
+            $this->assertTrue(is_numeric($port), 'POSTGRES_PORT must be numeric');
+        }
+    }
+
+    /**
+     * Verifies RabbitMQ environment variables
+     *
+     * @group integration
+     */
+    public function testRabbitMQEnvVariables(): void
+    {
+        $host = getenv('RABBIT_HOST');
+        $user = getenv('RABBIT_USER');
+        $password = getenv('RABBIT_PASSWORD');
+
+        // Skip if not configured
+        if ($host === false && $user === false && $password === false) {
+            $this->markTestSkipped('RabbitMQ env vars not loaded');
+        }
+
+        if ($host !== false) {
+            $this->assertNotEmpty($host, 'RABBIT_HOST should not be empty');
+        }
+        if ($user !== false) {
+            $this->assertNotEmpty($user, 'RABBIT_USER should not be empty (required for AMQP DSN)');
+        }
+        if ($password !== false) {
+            $this->assertNotEmpty($password, 'RABBIT_PASSWORD should not be empty');
+        }
+    }
+
+    /**
+     * Verifies Telegram bot token format
+     *
+     * @group integration
+     */
+    public function testTelegramBotToken(): void
+    {
+        $token = getenv('TELEGRAM_BOT_TOKEN');
+
+        if ($token === false) {
+            $this->markTestSkipped('TELEGRAM_BOT_TOKEN not set in test environment');
+        }
+
+        $this->assertNotEmpty($token, 'TELEGRAM_BOT_TOKEN is empty');
+        // Format: numeric_id:alphanumeric_part
+        $this->assertMatchesRegularExpression(
+            '/^\d+:[A-Za-z0-9_-]+$/',
+            $token,
+            'TELEGRAM_BOT_TOKEN has invalid format'
+        );
+    }
+
+    /**
+     * Verifies camera environment variables (optional)
+     * Cameras are optional - only check cameras that are configured
+     *
+     * @group integration
+     */
+    public function testCameraEnvVariables(): void
+    {
+        $configuredCameras = 0;
+
+        // Check only cameras that have LOGIN configured
+        for ($i = 1; $i <= 5; $i++) {
+            $login = getenv("CAMERA_{$i}_LOGIN");
+
+            // Skip cameras that are not configured
+            if ($login === false || $login === '') {
+                continue;
+            }
+
+            $configuredCameras++;
+            $password = getenv("CAMERA_{$i}_PASSWORD");
+
+            // If login is set, password should also be set
+            $this->assertNotFalse($password, "CAMERA_{$i}_PASSWORD not set but LOGIN is configured");
+        }
+
+        // Skip if no cameras configured at all
+        if ($configuredCameras === 0) {
+            $this->markTestSkipped('No camera env vars configured');
+        }
+
+        $this->assertGreaterThan(0, $configuredCameras, 'At least one camera should be configured');
+    }
+
+    /**
+     * Verifies security environment variables
+     *
+     * @group integration
+     */
+    public function testSecurityEnvVariables(): void
+    {
+        $cookieKey = getenv('COOKIE_VALIDATION_KEY');
+        $switchPassword = getenv('SWITCH_USER_COOKIE_PASSWORD');
+
+        if ($cookieKey === false) {
+            $this->markTestSkipped('Security env vars not loaded');
+        }
+
+        $this->assertNotEmpty($cookieKey, 'COOKIE_VALIDATION_KEY is empty');
+        $this->assertGreaterThan(10, strlen($cookieKey), 'COOKIE_VALIDATION_KEY is too short');
+
+        if ($switchPassword !== false) {
+            $this->assertNotEmpty($switchPassword, 'SWITCH_USER_COOKIE_PASSWORD is empty');
+        }
+    }
+
+    /**
+     * Verifies API keys (optional)
+     * Keys may not be set in test environments
+     *
+     * @group integration
+     */
+    public function testApiKeysEnvVariables(): void
+    {
+        $whatsappKey = getenv('WHATSAPP_API_KEY');
+        $yandexKey = getenv('YANDEX_MARKET_API_KEY');
+
+        // Both are optional in test environment
+        if ($whatsappKey === false && $yandexKey === false) {
+            $this->markTestSkipped('API keys not configured in test environment');
+        }
+
+        // If WhatsApp key is set, validate UUID format
+        if ($whatsappKey !== false && !empty($whatsappKey)) {
+            $this->assertMatchesRegularExpression(
+                '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
+                $whatsappKey,
+                'WHATSAPP_API_KEY must be in UUID format'
+            );
+        }
+
+        // Yandex key just needs to exist if set
+        if ($yandexKey !== false) {
+            $this->assertNotEmpty($yandexKey, 'YANDEX_MARKET_API_KEY is empty');
+        }
+    }
+
+    /**
+     * Verifies remote database environment variables (optional)
+     *
+     * @group integration
+     */
+    public function testRemoteDatabaseEnvVariables(): void
+    {
+        $host = getenv('DB_REMOTE_HOST');
+
+        // Remote DB is optional
+        if ($host === false) {
+            $this->markTestSkipped('Remote database env vars not configured');
+        }
+
+        $port = getenv('DB_REMOTE_PORT');
+        $schema = getenv('DB_REMOTE_SCHEMA');
+        $user = getenv('DB_REMOTE_USER');
+        $password = getenv('DB_REMOTE_PASSWORD');
+
+        $this->assertNotEmpty($host, 'DB_REMOTE_HOST is empty');
+        if ($port !== false) {
+            $this->assertTrue(is_numeric($port), 'DB_REMOTE_PORT must be numeric');
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/EnvExampleCompletenessTest.php b/erp24/tests/unit/config/EnvExampleCompletenessTest.php
new file mode 100644 (file)
index 0000000..b01ec86
--- /dev/null
@@ -0,0 +1,393 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for .env.example completeness
+ *
+ * Verifies that .env.example contains all required environment variables
+ * for the secrets migration (ERP-500).
+ *
+ * Covers:
+ * - erp24/.env.example
+ *
+ * @group config
+ * @group env
+ * @group secrets
+ */
+class EnvExampleCompletenessTest extends Unit
+{
+    private string $envExamplePath;
+    private string $envExampleContent;
+
+    /**
+     * All required ENV variables that must be in .env.example
+     */
+    private array $requiredVariables = [
+        // Application
+        'APP_ENV',
+        'SERVER_NAME',
+
+        // PostgreSQL
+        'POSTGRES_HOSTNAME',
+        'POSTGRES_PORT',
+        'POSTGRES_SCHEMA',
+        'POSTGRES_USER',
+        'POSTGRES_PASSWORD',
+
+        // MySQL
+        'DB_HOST',
+        'DB_PORT',
+        'DB_SCHEMA',
+        'DB_USER',
+        'DB_PASSWORD',
+
+        // Remote CMS
+        'DB_REMOTE_HOST',
+        'DB_REMOTE_PORT',
+        'DB_REMOTE_SCHEMA',
+        'DB_REMOTE_USER',
+        'DB_REMOTE_PASSWORD',
+
+        // RabbitMQ
+        'RABBIT_HOST',
+        'RABBIT_USER',
+        'RABBIT_PASSWORD',
+
+        // Telegram
+        'TELEGRAM_BOT_TOKEN',
+        'TELEGRAM_BOT_TOKEN_PROD',
+        'TELEGRAM_BOT_TOKEN_SALEBOT',
+        'TELEGRAM_BOT_ALERTS',
+        'TELEGRAM_BOT_ORDERS',
+        'TELEGRAM_CHAT_CHANNEL_ID',
+        'TELEGRAM_CHAT_CHANNEL_ERP_ID',
+        'CHATBOT_SALT',
+
+        // API Keys
+        'WHATSAPP_API_KEY',
+        'YANDEX_MARKET_API_KEY',
+
+        // Security
+        'COOKIE_VALIDATION_KEY',
+        'COOKIE_VALIDATION_KEY_API2',
+        'SWITCH_USER_COOKIE_PASSWORD',
+
+        // LPTracker
+        'LPTRACKER_LOGIN',
+        'LPTRACKER_PASSWORD',
+
+        // GreenSMS
+        'GREENSMS_API_KEY',
+
+        // SMS.RU
+        'SMSRU_API_KEY',
+
+        // API Tokens
+        'API_TOKEN',
+        'TOKEN_CLOUD',
+
+        // AmoCRM
+        'AMO_CLIENT_ID',
+        'AMO_CLIENT_SECRET',
+        'AMO_SECRET_PHRASE',
+
+        // CloudPayments
+        'CLOUDPAYMENTS_PUBLIC_ID',
+        'CLOUDPAYMENTS_SECRET',
+        'CLOUDPAYMENTS_REGION_PUBLIC_ID',
+        'CLOUDPAYMENTS_REGION_SECRET',
+
+        // BonusPlus
+        'BONUSPLUS_API_KEY',
+
+        // 1C Integration
+        'EXPORT_TOKEN_1C',
+        'FTP_1C_HOST',
+        'FTP_1C_USER',
+        'FTP_1C_PASSWORD',
+
+        // Email
+        'EMAIL_ZAKAZ_PASSWORD',
+        'EMAIL_FLOW_PASSWORD',
+
+        // HTTP Basic Auth
+        'BASIC_AUTH_KASSEAR_USER',
+        'BASIC_AUTH_KASSEAR_PASSWORD',
+        'BASIC_AUTH_DEMO2_USER',
+        'BASIC_AUTH_DEMO2_PASSWORD',
+
+        // Cameras (6 cameras for Дом.ру)
+        'CAMERA_1_LOGIN',
+        'CAMERA_1_PASSWORD',
+        'CAMERA_2_LOGIN',
+        'CAMERA_2_PASSWORD',
+        'CAMERA_3_LOGIN',
+        'CAMERA_3_PASSWORD',
+        'CAMERA_4_LOGIN',
+        'CAMERA_4_PASSWORD',
+        'CAMERA_5_LOGIN',
+        'CAMERA_5_PASSWORD',
+        'CAMERA_6_LOGIN',
+        'CAMERA_6_PASSWORD',
+
+        // IMAP (for mail processing)
+        'IMAP_EMAIL',
+        'IMAP_PASSWORD',
+    ];
+
+    protected function _before(): void
+    {
+        $this->envExamplePath = dirname(__DIR__, 3) . '/.env.example';
+        $this->envExampleContent = file_get_contents($this->envExamplePath);
+    }
+
+    /**
+     * Test .env.example file exists
+     */
+    public function testEnvExampleFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->envExamplePath,
+            '.env.example must exist'
+        );
+    }
+
+    /**
+     * Test all required variables are present
+     */
+    public function testAllRequiredVariablesPresent(): void
+    {
+        $missing = [];
+
+        foreach ($this->requiredVariables as $var) {
+            if (strpos($this->envExampleContent, $var . '=') === false) {
+                $missing[] = $var;
+            }
+        }
+
+        $this->assertEmpty(
+            $missing,
+            'Missing required ENV variables in .env.example: ' . implode(', ', $missing)
+        );
+    }
+
+    /**
+     * Test Telegram variables section
+     */
+    public function testTelegramSectionComplete(): void
+    {
+        $telegramVars = [
+            'TELEGRAM_BOT_TOKEN',
+            'TELEGRAM_BOT_TOKEN_PROD',
+            'TELEGRAM_BOT_TOKEN_SALEBOT',
+            'TELEGRAM_CHAT_CHANNEL_ID',
+            'TELEGRAM_CHAT_CHANNEL_ERP_ID',
+            'CHATBOT_SALT',
+        ];
+
+        foreach ($telegramVars as $var) {
+            $this->assertStringContainsString(
+                $var . '=',
+                $this->envExampleContent,
+                "Telegram variable $var must be in .env.example"
+            );
+        }
+    }
+
+    /**
+     * Test CloudPayments variables section
+     */
+    public function testCloudPaymentsSectionComplete(): void
+    {
+        $vars = [
+            'CLOUDPAYMENTS_PUBLIC_ID',
+            'CLOUDPAYMENTS_SECRET',
+            'CLOUDPAYMENTS_REGION_PUBLIC_ID',
+            'CLOUDPAYMENTS_REGION_SECRET',
+        ];
+
+        foreach ($vars as $var) {
+            $this->assertStringContainsString(
+                $var . '=',
+                $this->envExampleContent,
+                "CloudPayments variable $var must be in .env.example"
+            );
+        }
+    }
+
+    /**
+     * Test LPTracker variables section
+     */
+    public function testLpTrackerSectionComplete(): void
+    {
+        $this->assertStringContainsString(
+            'LPTRACKER_LOGIN=',
+            $this->envExampleContent,
+            'LPTRACKER_LOGIN must be in .env.example'
+        );
+
+        $this->assertStringContainsString(
+            'LPTRACKER_PASSWORD=',
+            $this->envExampleContent,
+            'LPTRACKER_PASSWORD must be in .env.example'
+        );
+    }
+
+    /**
+     * Test Email variables section
+     */
+    public function testEmailSectionComplete(): void
+    {
+        $this->assertStringContainsString(
+            'EMAIL_ZAKAZ_PASSWORD=',
+            $this->envExampleContent,
+            'EMAIL_ZAKAZ_PASSWORD must be in .env.example'
+        );
+
+        $this->assertStringContainsString(
+            'EMAIL_FLOW_PASSWORD=',
+            $this->envExampleContent,
+            'EMAIL_FLOW_PASSWORD must be in .env.example'
+        );
+    }
+
+    /**
+     * Test IMAP variables section
+     */
+    public function testImapSectionComplete(): void
+    {
+        $this->assertStringContainsString(
+            'IMAP_EMAIL=',
+            $this->envExampleContent,
+            'IMAP_EMAIL must be in .env.example'
+        );
+
+        $this->assertStringContainsString(
+            'IMAP_PASSWORD=',
+            $this->envExampleContent,
+            'IMAP_PASSWORD must be in .env.example'
+        );
+    }
+
+    /**
+     * Test Cameras section complete (6 cameras)
+     */
+    public function testCamerasSectionComplete(): void
+    {
+        for ($i = 1; $i <= 6; $i++) {
+            $this->assertStringContainsString(
+                "CAMERA_{$i}_LOGIN=",
+                $this->envExampleContent,
+                "CAMERA_{$i}_LOGIN must be in .env.example"
+            );
+
+            $this->assertStringContainsString(
+                "CAMERA_{$i}_PASSWORD=",
+                $this->envExampleContent,
+                "CAMERA_{$i}_PASSWORD must be in .env.example"
+            );
+        }
+    }
+
+    /**
+     * Test GreenSMS variables section
+     */
+    public function testGreenSmsSectionComplete(): void
+    {
+        $this->assertStringContainsString(
+            'GREENSMS_API_KEY=',
+            $this->envExampleContent,
+            'GREENSMS_API_KEY must be in .env.example'
+        );
+    }
+
+    /**
+     * Test BonusPlus variables section
+     */
+    public function testBonusPlusSectionComplete(): void
+    {
+        $this->assertStringContainsString(
+            'BONUSPLUS_API_KEY=',
+            $this->envExampleContent,
+            'BONUSPLUS_API_KEY must be in .env.example'
+        );
+    }
+
+    /**
+     * Test .env.example has proper section headers
+     */
+    public function testHasSectionHeaders(): void
+    {
+        $sections = [
+            'APPLICATION',
+            'DATABASE',
+            'RABBITMQ',
+            'TELEGRAM',
+            'SECURITY',
+            'LPTRACKER',
+            'PAYMENTS',
+            'EMAIL',
+        ];
+
+        foreach ($sections as $section) {
+            $this->assertMatchesRegularExpression(
+                '/=+\s*' . $section . '\s*=+|#\s*=+\s*' . $section . '/i',
+                $this->envExampleContent,
+                "Section header for $section should exist"
+            );
+        }
+    }
+
+    /**
+     * Test .env.example has comments for sensitive fields
+     */
+    public function testHasCommentsForSensitiveFields(): void
+    {
+        // Check that there are helpful comments
+        $this->assertStringContainsString(
+            'DEV STUB',
+            $this->envExampleContent,
+            '.env.example should have DEV STUB comments for sensitive fields'
+        );
+    }
+
+    /**
+     * Test no real secrets in .env.example
+     */
+    public function testNoRealSecretsInEnvExample(): void
+    {
+        // Known patterns that should NOT be in .env.example
+        $forbiddenPatterns = [
+            '/\d{10}:AA[A-Za-z0-9_-]{33}/', // Real Telegram token
+            '/pk_[a-f0-9]{30,}/',            // Real CloudPayments key
+            '/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/', // Real UUID (except as example)
+        ];
+
+        foreach ($forbiddenPatterns as $pattern) {
+            // Allow the placeholder UUID format but not real-looking ones
+            if ($pattern !== '/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/') {
+                $this->assertDoesNotMatchRegularExpression(
+                    $pattern,
+                    $this->envExampleContent,
+                    ".env.example must not contain real secrets matching pattern: $pattern"
+                );
+            }
+        }
+    }
+
+    /**
+     * Test placeholder values are used for sensitive fields
+     */
+    public function testPlaceholderValuesForSensitiveFields(): void
+    {
+        // Check that sensitive fields have placeholder or empty values
+        $this->assertMatchesRegularExpression(
+            '/POSTGRES_PASSWORD=dev_password_change_me|POSTGRES_PASSWORD=$/',
+            $this->envExampleContent,
+            'POSTGRES_PASSWORD should have placeholder or empty value'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/GreenSmsSecretsTest.php b/erp24/tests/unit/config/GreenSmsSecretsTest.php
new file mode 100644 (file)
index 0000000..dd6cd13
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for GreenSMS greensms.php getenv() migration
+ *
+ * Verifies that greensms.php uses environment variables
+ * for API key credential (1 secret).
+ *
+ * Covers changes in:
+ * - erp24/modul/api/greensms.php
+ *
+ * ENV variables tested:
+ * - GREENSMS_API_KEY
+ *
+ * @group config
+ * @group greensms
+ * @group secrets
+ */
+class GreenSmsSecretsTest extends Unit
+{
+    private string $filePath;
+
+    protected function _before(): void
+    {
+        $this->filePath = dirname(__DIR__, 3) . '/modul/api/greensms.php';
+    }
+
+    /**
+     * Test greensms.php file exists
+     */
+    public function testFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->filePath,
+            'greensms.php must exist'
+        );
+    }
+
+    /**
+     * Test GREENSMS_API_KEY uses getenv
+     */
+    public function testApiKeyUsesEnv(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            "getenv('GREENSMS_API_KEY')",
+            $content,
+            'greensms.php must use GREENSMS_API_KEY from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded API key
+     */
+    public function testNoHardcodedApiKey(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that $apikey is not assigned a literal string
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$apikey\s*=\s*[\'"][a-zA-Z0-9]{20,}[\'"];/',
+            $content,
+            'greensms.php must not have hardcoded API key'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback
+     */
+    public function testGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'GREENSMS_API_KEY'\s*\)\s*\?:\s*''/",
+            $content,
+            'GREENSMS_API_KEY must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+
+        $this->assertStringContainsString(
+            'GREENSMS_API_KEY',
+            $content,
+            'PHPDoc must mention GREENSMS_API_KEY'
+        );
+    }
+
+    /**
+     * Test API key is used in Authorization header
+     */
+    public function testApiKeyUsedInAuthorization(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that $apikey is used in Authorization header
+        $this->assertStringContainsString(
+            '"Authorization: $apikey"',
+            $content,
+            'API key must be used in Authorization header'
+        );
+    }
+
+    /**
+     * Test API key is used in JSON payload
+     */
+    public function testApiKeyUsedInJsonPayload(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that $apikey variable is used in JSON security block
+        // Pattern in code: "apiKey": "'.$apikey.'"
+        $this->assertStringContainsString(
+            '$apikey',
+            $content,
+            'API key variable must be used in JSON security payload'
+        );
+
+        $this->assertStringContainsString(
+            'apiKey',
+            $content,
+            'apiKey field must be present in JSON payload'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/ImapSecretsTest.php b/erp24/tests/unit/config/ImapSecretsTest.php
new file mode 100644 (file)
index 0000000..2e19cb6
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for IMAP credentials in mail.php getenv() migration
+ *
+ * Verifies that mail.php uses environment variables
+ * for IMAP email credentials.
+ *
+ * Covers changes in:
+ * - erp24/inc/mail.php
+ *
+ * ENV variables tested:
+ * - IMAP_EMAIL
+ * - IMAP_PASSWORD
+ *
+ * @group config
+ * @group imap
+ * @group secrets
+ */
+class ImapSecretsTest extends Unit
+{
+    private string $filePath;
+
+    protected function _before(): void
+    {
+        $this->filePath = dirname(__DIR__, 3) . '/inc/mail.php';
+    }
+
+    /**
+     * Test mail.php file exists
+     */
+    public function testFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->filePath,
+            'mail.php must exist'
+        );
+    }
+
+    /**
+     * Test IMAP_EMAIL uses getenv in rmail function
+     */
+    public function testImapEmailUsesEnv(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            "getenv('IMAP_EMAIL')",
+            $content,
+            'mail.php must use IMAP_EMAIL from env'
+        );
+    }
+
+    /**
+     * Test IMAP_PASSWORD uses getenv
+     */
+    public function testImapPasswordUsesEnv(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            "getenv('IMAP_PASSWORD')",
+            $content,
+            'mail.php must use IMAP_PASSWORD from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded email address
+     */
+    public function testNoHardcodedEmail(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that doors-click@yandex.ru is not used as a value assignment
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$mail_login\s*=\s*[\'"]doors-click@yandex\.ru[\'"]\s*;/',
+            $content,
+            'mail.php must not have hardcoded email address'
+        );
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$email\s*=\s*[\'"]doors-click@yandex\.ru[\'"]\s*;/',
+            $content,
+            'mail.php must not have hardcoded email in $email variable'
+        );
+    }
+
+    /**
+     * Test no hardcoded password
+     */
+    public function testNoHardcodedPassword(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that VlrVtnwjCg462vs is not present
+        $this->assertStringNotContainsString(
+            'VlrVtnwjCg462vs',
+            $content,
+            'mail.php must not have hardcoded IMAP password'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback for email
+     */
+    public function testEmailGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'IMAP_EMAIL'\s*\)\s*\?:\s*''/",
+            $content,
+            'IMAP_EMAIL must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback for password
+     */
+    public function testPasswordGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'IMAP_PASSWORD'\s*\)\s*\?:\s*''/",
+            $content,
+            'IMAP_PASSWORD must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+
+        $this->assertStringContainsString(
+            'IMAP_EMAIL',
+            $content,
+            'PHPDoc must mention IMAP_EMAIL'
+        );
+
+        $this->assertStringContainsString(
+            'IMAP_PASSWORD',
+            $content,
+            'PHPDoc must mention IMAP_PASSWORD'
+        );
+    }
+
+    /**
+     * Test imap_open uses getenv credentials
+     */
+    public function testImapOpenUsesEnvCredentials(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Check that imap_open is called with variables from getenv
+        // Pattern: getenv('IMAP_EMAIL') should be used before imap_open
+        $emailEnvPos = strpos($content, "getenv('IMAP_EMAIL')");
+        $imapOpenPos = strpos($content, 'imap_open');
+
+        $this->assertNotFalse($emailEnvPos, 'getenv(IMAP_EMAIL) must be present');
+        $this->assertNotFalse($imapOpenPos, 'imap_open must be present');
+    }
+
+    /**
+     * Test multiple IMAP functions use getenv
+     */
+    public function testMultipleImapFunctionsUseEnv(): void
+    {
+        $content = file_get_contents($this->filePath);
+
+        // Count occurrences of getenv('IMAP_EMAIL')
+        $emailCount = substr_count($content, "getenv('IMAP_EMAIL')");
+        $passwordCount = substr_count($content, "getenv('IMAP_PASSWORD')");
+
+        // Should be used at least 3 times (rmail, reader_mail, commented section)
+        $this->assertGreaterThanOrEqual(
+            3,
+            $emailCount,
+            'IMAP_EMAIL getenv should be used in multiple places'
+        );
+
+        $this->assertGreaterThanOrEqual(
+            3,
+            $passwordCount,
+            'IMAP_PASSWORD getenv should be used in multiple places'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/LegacyDbSecretsTest.php b/erp24/tests/unit/config/LegacyDbSecretsTest.php
new file mode 100644 (file)
index 0000000..385fb24
--- /dev/null
@@ -0,0 +1,457 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for legacy database configuration secrets
+ *
+ * Verifies that legacy database configuration files use environment
+ * variables instead of hardcoded credentials.
+ *
+ * Covers:
+ * - erp24/inc/db2.php (remote database connection)
+ * - erp24/inc/crmconf.php (legacy CRM configuration)
+ *
+ * @group config
+ * @group database
+ * @group secrets
+ */
+class LegacyDbSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // inc/db2.php tests
+    // ============================================================
+
+    /**
+     * Test db2.php exists
+     */
+    public function testDb2FileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $this->assertFileExists($filePath, 'inc/db2.php must exist');
+    }
+
+    /**
+     * Test db2.php is valid PHP
+     */
+    public function testDb2IsValidPhp(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$filePath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'inc/db2.php must be valid PHP: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test db2.php uses DB_REMOTE_HOST env
+     */
+    public function testDb2UsesRemoteHostEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_HOST[\'"]\s*\)/',
+            $content,
+            'db2.php must use getenv(\'DB_REMOTE_HOST\')'
+        );
+    }
+
+    /**
+     * Test db2.php uses DB_REMOTE_USER env
+     */
+    public function testDb2UsesRemoteUserEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_USER[\'"]\s*\)/',
+            $content,
+            'db2.php must use getenv(\'DB_REMOTE_USER\')'
+        );
+    }
+
+    /**
+     * Test db2.php uses DB_REMOTE_PASSWORD env
+     */
+    public function testDb2UsesRemotePasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_PASSWORD[\'"]\s*\)/',
+            $content,
+            'db2.php must use getenv(\'DB_REMOTE_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test db2.php uses DB_REMOTE_SCHEMA env
+     */
+    public function testDb2UsesRemoteSchemaEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_SCHEMA[\'"]\s*\)/',
+            $content,
+            'db2.php must use getenv(\'DB_REMOTE_SCHEMA\')'
+        );
+    }
+
+    /**
+     * Test db2.php has no hardcoded password
+     */
+    public function testDb2NoHardcodedPassword(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        // Should not contain hardcoded password in config array
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"]DB_PASSWORD[\'"]\s*=>\s*[\'"][A-Za-z0-9_]{8,}[\'"]/',
+            $content,
+            'db2.php must not contain hardcoded DB_PASSWORD'
+        );
+    }
+
+    /**
+     * Test db2.php has no hardcoded user
+     */
+    public function testDb2NoHardcodedUser(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        // Should not have hardcoded username (except in comments)
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"]DB_USER[\'"]\s*=>\s*[\'"]bazacvetov24[\'"]/',
+            $content,
+            'db2.php must not contain hardcoded DB_USER'
+        );
+    }
+
+    /**
+     * Test db2.php getenv has fallbacks
+     */
+    public function testDb2HasFallbacks(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        // Host should have default fallback
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_HOST[\'"]\s*\)\s*\?:/',
+            $content,
+            'DB_REMOTE_HOST getenv must have fallback'
+        );
+
+        // User/Password should have empty fallback
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_USER[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'DB_REMOTE_USER getenv must have empty fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_REMOTE_PASSWORD[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'DB_REMOTE_PASSWORD getenv must have empty fallback'
+        );
+    }
+
+    /**
+     * Test db2.php has conditional initialization
+     */
+    public function testDb2HasConditionalInit(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        // DB2 should only be initialized if credentials are provided
+        $this->assertMatchesRegularExpression(
+            '/if\s*\(\s*!empty\s*\(\s*\$config2\[.DB_USER.\]\s*\)/',
+            $content,
+            'db2.php must check for DB_USER before initializing'
+        );
+    }
+
+    /**
+     * Test db2.php has PHP doc
+     */
+    public function testDb2HasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/db2.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'db2.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // inc/crmconf.php tests
+    // ============================================================
+
+    /**
+     * Test crmconf.php exists
+     */
+    public function testCrmconfFileExists(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $this->assertFileExists($filePath, 'inc/crmconf.php must exist');
+    }
+
+    /**
+     * Test crmconf.php is valid PHP
+     */
+    public function testCrmconfIsValidPhp(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$filePath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'inc/crmconf.php must be valid PHP: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test crmconf.php uses DB_HOST env
+     */
+    public function testCrmconfUsesDbHostEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_HOST[\'"]\s*\)/',
+            $content,
+            'crmconf.php must use getenv(\'DB_HOST\')'
+        );
+    }
+
+    /**
+     * Test crmconf.php uses DB_SCHEMA env
+     */
+    public function testCrmconfUsesDbSchemaEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_SCHEMA[\'"]\s*\)/',
+            $content,
+            'crmconf.php must use getenv(\'DB_SCHEMA\')'
+        );
+    }
+
+    /**
+     * Test crmconf.php uses DB_USER env
+     */
+    public function testCrmconfUsesDbUserEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_USER[\'"]\s*\)/',
+            $content,
+            'crmconf.php must use getenv(\'DB_USER\')'
+        );
+    }
+
+    /**
+     * Test crmconf.php uses DB_PASSWORD env
+     */
+    public function testCrmconfUsesDbPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_PASSWORD[\'"]\s*\)/',
+            $content,
+            'crmconf.php must use getenv(\'DB_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test crmconf.php has no hardcoded mysql_pass
+     */
+    public function testCrmconfNoHardcodedMysqlPass(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        // Should not contain hardcoded password
+        $this->assertDoesNotMatchRegularExpression(
+            '/\[.mysql_pass.\]\s*=\s*["\'][A-Za-z0-9_]{8,}["\']/',
+            $content,
+            'crmconf.php must not contain hardcoded mysql_pass'
+        );
+    }
+
+    /**
+     * Test crmconf.php has no hardcoded mysql_user
+     */
+    public function testCrmconfNoHardcodedMysqlUser(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        // Should not have hardcoded username
+        $this->assertDoesNotMatchRegularExpression(
+            '/\[.mysql_user.\]\s*=\s*["\']bazacvetov24["\']/',
+            $content,
+            'crmconf.php must not contain hardcoded mysql_user'
+        );
+    }
+
+    /**
+     * Test crmconf.php getenv has fallbacks
+     */
+    public function testCrmconfHasFallbacks(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]DB_HOST[\'"]\s*\)\s*\?:/',
+            $content,
+            'DB_HOST getenv must have fallback'
+        );
+    }
+
+    /**
+     * Test crmconf.php has PHP doc
+     */
+    public function testCrmconfHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'crmconf.php must have documentation comment'
+        );
+    }
+
+    /**
+     * Test crmconf.php references .env.example
+     */
+    public function testCrmconfReferencesEnvExample(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            '@see erp24/.env.example',
+            $content,
+            'crmconf.php must reference .env.example'
+        );
+    }
+
+    // ============================================================
+    // Cross-file consistency tests
+    // ============================================================
+
+    /**
+     * Test no known leaked passwords in legacy DB files
+     */
+    public function testNoKnownLeakedPasswords(): void
+    {
+        $files = [
+            $this->basePath . '/inc/db2.php',
+            $this->basePath . '/inc/crmconf.php',
+        ];
+
+        $leakedPasswords = [
+            'JVJruro_Xdg456o3ir',
+            'hQ6xG5aQ7paV5q',  // Password from comments
+        ];
+
+        foreach ($files as $file) {
+            $content = file_get_contents($file);
+
+            foreach ($leakedPasswords as $password) {
+                $this->assertStringNotContainsString(
+                    $password,
+                    $content,
+                    basename($file) . " must not contain leaked password"
+                );
+            }
+        }
+    }
+
+    /**
+     * Test .env.example has all required DB_REMOTE variables
+     */
+    public function testEnvExampleHasRemoteDbVariables(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $requiredVars = [
+            'DB_REMOTE_HOST',
+            'DB_REMOTE_PORT',
+            'DB_REMOTE_SCHEMA',
+            'DB_REMOTE_USER',
+            'DB_REMOTE_PASSWORD',
+        ];
+
+        foreach ($requiredVars as $var) {
+            $this->assertStringContainsString(
+                $var . '=',
+                $envExample,
+                ".env.example must contain {$var}"
+            );
+        }
+    }
+
+    /**
+     * Test both $_CONFIG_SITE and $CONFIG_SETKA use getenv
+     */
+    public function testBothConfigArraysUseGetenv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        // Check $_CONFIG_SITE uses getenv for mysql_pass
+        $this->assertMatchesRegularExpression(
+            '/\$_CONFIG_SITE\[.mysql_pass.\]\s*=\s*getenv/',
+            $content,
+            '$_CONFIG_SITE["mysql_pass"] must use getenv'
+        );
+
+        // Check $CONFIG_SETKA uses getenv for mysql_pass
+        $this->assertMatchesRegularExpression(
+            '/\$CONFIG_SETKA\[.mysql_pass.\]\s*=\s*getenv/',
+            $content,
+            '$CONFIG_SETKA["mysql_pass"] must use getenv'
+        );
+    }
+}
diff --git a/erp24/tests/unit/config/LegacyTelegramBotsSecretsTest.php b/erp24/tests/unit/config/LegacyTelegramBotsSecretsTest.php
new file mode 100644 (file)
index 0000000..d7c728b
--- /dev/null
@@ -0,0 +1,322 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for legacy Telegram bots secrets migration
+ *
+ * Verifies that legacy Telegram bot files use environment variables
+ * instead of hardcoded tokens.
+ *
+ * Covers:
+ * - erp24/api1_old/telegram/bc24_alerts_bot.php
+ * - erp24/api1_old/telegram/OrderFlowersBaza24Bot.php
+ * - erp24/modul/config/telegram_alerts.php
+ *
+ * @group config
+ * @group telegram
+ * @group secrets
+ */
+class LegacyTelegramBotsSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // bc24_alerts_bot.php tests
+    // ============================================================
+
+    /**
+     * Test bc24_alerts_bot.php exists
+     */
+    public function testAlertsBot24FileExists(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php';
+        $this->assertFileExists($filePath, 'bc24_alerts_bot.php must exist');
+    }
+
+    /**
+     * Test bc24_alerts_bot.php uses TELEGRAM_BOT_ALERTS env
+     */
+    public function testAlertsBot24UsesEnv(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ALERTS[\'"]\s*\)/',
+            $content,
+            'bc24_alerts_bot.php must use getenv(\'TELEGRAM_BOT_ALERTS\')'
+        );
+    }
+
+    /**
+     * Test bc24_alerts_bot.php has no hardcoded token
+     */
+    public function testAlertsBot24NoHardcodedToken(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php';
+        $content = file_get_contents($filePath);
+
+        // Real Telegram token format
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"][0-9]{9,11}:AA[A-Za-z0-9_-]{30,}[\'"]/',
+            $content,
+            'bc24_alerts_bot.php must not contain hardcoded bot token'
+        );
+    }
+
+    /**
+     * Test bc24_alerts_bot.php getenv has fallback
+     */
+    public function testAlertsBot24HasFallback(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ALERTS[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'bc24_alerts_bot.php getenv must have empty fallback'
+        );
+    }
+
+    /**
+     * Test bc24_alerts_bot.php has PHP doc
+     */
+    public function testAlertsBot24HasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'bc24_alerts_bot.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // OrderFlowersBaza24Bot.php tests
+    // ============================================================
+
+    /**
+     * Test OrderFlowersBaza24Bot.php exists
+     */
+    public function testOrdersBotFileExists(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php';
+        $this->assertFileExists($filePath, 'OrderFlowersBaza24Bot.php must exist');
+    }
+
+    /**
+     * Test OrderFlowersBaza24Bot.php uses TELEGRAM_BOT_ORDERS env
+     */
+    public function testOrdersBotUsesEnv(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ORDERS[\'"]\s*\)/',
+            $content,
+            'OrderFlowersBaza24Bot.php must use getenv(\'TELEGRAM_BOT_ORDERS\')'
+        );
+    }
+
+    /**
+     * Test OrderFlowersBaza24Bot.php has no hardcoded token
+     */
+    public function testOrdersBotNoHardcodedToken(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"][0-9]{9,11}:AA[A-Za-z0-9_-]{30,}[\'"]/',
+            $content,
+            'OrderFlowersBaza24Bot.php must not contain hardcoded bot token'
+        );
+    }
+
+    /**
+     * Test OrderFlowersBaza24Bot.php getenv has fallback
+     */
+    public function testOrdersBotHasFallback(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ORDERS[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'OrderFlowersBaza24Bot.php getenv must have empty fallback'
+        );
+    }
+
+    /**
+     * Test OrderFlowersBaza24Bot.php has PHP doc
+     */
+    public function testOrdersBotHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'OrderFlowersBaza24Bot.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // telegram_alerts.php tests
+    // ============================================================
+
+    /**
+     * Test telegram_alerts.php exists
+     */
+    public function testTelegramAlertsFileExists(): void
+    {
+        $filePath = $this->basePath . '/modul/config/telegram_alerts.php';
+        $this->assertFileExists($filePath, 'telegram_alerts.php must exist');
+    }
+
+    /**
+     * Test telegram_alerts.php uses TELEGRAM_BOT_ALERTS env
+     */
+    public function testTelegramAlertsUsesEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/config/telegram_alerts.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ALERTS[\'"]\s*\)/',
+            $content,
+            'telegram_alerts.php must use getenv(\'TELEGRAM_BOT_ALERTS\')'
+        );
+    }
+
+    /**
+     * Test telegram_alerts.php has no hardcoded token
+     */
+    public function testTelegramAlertsNoHardcodedToken(): void
+    {
+        $filePath = $this->basePath . '/modul/config/telegram_alerts.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"][0-9]{9,11}:AA[A-Za-z0-9_-]{30,}[\'"]/',
+            $content,
+            'telegram_alerts.php must not contain hardcoded bot token'
+        );
+    }
+
+    /**
+     * Test telegram_alerts.php getenv has fallback
+     */
+    public function testTelegramAlertsHasFallback(): void
+    {
+        $filePath = $this->basePath . '/modul/config/telegram_alerts.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_ALERTS[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'telegram_alerts.php getenv must have empty fallback'
+        );
+    }
+
+    /**
+     * Test telegram_alerts.php has PHP doc
+     */
+    public function testTelegramAlertsHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/modul/config/telegram_alerts.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'telegram_alerts.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // Cross-file consistency tests
+    // ============================================================
+
+    /**
+     * Test all legacy Telegram files use define() for TOKEN
+     */
+    public function testAllFilesUseDefineForToken(): void
+    {
+        $files = [
+            $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php',
+            $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php',
+            $this->basePath . '/modul/config/telegram_alerts.php',
+        ];
+
+        foreach ($files as $file) {
+            $content = file_get_contents($file);
+            $this->assertMatchesRegularExpression(
+                '/define\s*\(\s*[\'"]TOKEN[\'"]\s*,/',
+                $content,
+                basename($file) . ' must define TOKEN constant'
+            );
+        }
+    }
+
+    /**
+     * Test .env.example has all required Telegram bot variables
+     */
+    public function testEnvExampleHasAllBotVariables(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'TELEGRAM_BOT_ALERTS=',
+            $envExample,
+            '.env.example must contain TELEGRAM_BOT_ALERTS'
+        );
+
+        $this->assertStringContainsString(
+            'TELEGRAM_BOT_ORDERS=',
+            $envExample,
+            '.env.example must contain TELEGRAM_BOT_ORDERS'
+        );
+    }
+
+    /**
+     * Test no Telegram tokens in comments
+     */
+    public function testNoTokensInComments(): void
+    {
+        $files = [
+            $this->basePath . '/api1_old/telegram/bc24_alerts_bot.php',
+            $this->basePath . '/api1_old/telegram/OrderFlowersBaza24Bot.php',
+            $this->basePath . '/modul/config/telegram_alerts.php',
+            $this->basePath . '/modul/shipment/MessangerAdd.php',
+        ];
+
+        foreach ($files as $file) {
+            if (!file_exists($file)) {
+                continue;
+            }
+            $content = file_get_contents($file);
+
+            // Check for tokens in comments (// or /* */)
+            $this->assertDoesNotMatchRegularExpression(
+                '/(?:\/\/|\/\*|\*)[^\n]*[0-9]{9,11}:AA[A-Za-z0-9_-]{30,}/',
+                $content,
+                basename($file) . ' must not contain bot tokens in comments'
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/config/MediaConfigTest.php b/erp24/tests/unit/config/MediaConfigTest.php
new file mode 100644 (file)
index 0000000..8bdf156
--- /dev/null
@@ -0,0 +1,237 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for media.config.php getenv() migration
+ *
+ * Verifies that media.config.php uses environment variables
+ * for RabbitMQ credentials and cookie validation key.
+ *
+ * Covers changes in:
+ * - erp24/media/config/media.config.php
+ *
+ * @group config
+ * @group media
+ * @group acceptance
+ */
+class MediaConfigTest extends Unit
+{
+    private string $configPath;
+
+    protected function _before(): void
+    {
+        $this->configPath = dirname(__DIR__, 3) . '/media/config/media.config.php';
+    }
+
+    /**
+     * Test media.config.php file exists
+     */
+    public function testConfigFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->configPath,
+            'media.config.php must exist'
+        );
+    }
+
+    /**
+     * Test RabbitMQ DSN uses getenv with rawurlencode for RABBIT_USER
+     *
+     * rawurlencode is required because RabbitMQ credentials may contain
+     * special characters that need to be URL-encoded in AMQP DSN
+     */
+    public function testRabbitMqDsnUsesEnvUser(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        $this->assertStringContainsString(
+            "rawurlencode(getenv('RABBIT_USER')",
+            $content,
+            'RabbitMQ DSN must use rawurlencode for RABBIT_USER'
+        );
+    }
+
+    /**
+     * Test RabbitMQ DSN uses getenv with rawurlencode for RABBIT_PASSWORD
+     */
+    public function testRabbitMqDsnUsesEnvPassword(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        $this->assertStringContainsString(
+            "rawurlencode(getenv('RABBIT_PASSWORD')",
+            $content,
+            'RabbitMQ DSN must use rawurlencode for RABBIT_PASSWORD'
+        );
+    }
+
+    /**
+     * Test RabbitMQ DSN uses getenv for RABBIT_HOST
+     */
+    public function testRabbitMqDsnUsesEnvHost(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        $this->assertStringContainsString(
+            "getenv('RABBIT_HOST')",
+            $content,
+            'RabbitMQ DSN must use RABBIT_HOST from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded RabbitMQ password
+     *
+     * Old implementation had hardcoded password in DSN string
+     */
+    public function testNoHardcodedRabbitMqPassword(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Known old password pattern (masked)
+        $this->assertStringNotContainsString(
+            '3qqHK2MRgGgxUdVT61',
+            $content,
+            'Must not contain hardcoded RabbitMQ password'
+        );
+    }
+
+    /**
+     * Test no hardcoded RabbitMQ user
+     */
+    public function testNoHardcodedRabbitMqUser(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Check that user is not hardcoded in DSN
+        $this->assertDoesNotMatchRegularExpression(
+            "/amqp:\/\/[a-zA-Z0-9_]+:[^@]+@/",
+            $content,
+            'RabbitMQ DSN must not have hardcoded user:password pattern'
+        );
+    }
+
+    /**
+     * Test cookieValidationKey uses getenv
+     */
+    public function testCookieKeyUsesEnv(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        $this->assertStringContainsString(
+            "getenv('COOKIE_VALIDATION_KEY')",
+            $content,
+            'cookieValidationKey must use COOKIE_VALIDATION_KEY from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded cookie validation key
+     *
+     * Old implementation had hardcoded cookie key
+     */
+    public function testNoHardcodedCookieKey(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Known old cookie key pattern
+        $this->assertStringNotContainsString(
+            'erp24_DLVFJRBvmttertrrt_key',
+            $content,
+            'Must not contain hardcoded cookie validation key'
+        );
+    }
+
+    /**
+     * Test RabbitMQ DSN has proper AMQP format
+     */
+    public function testRabbitMqDsnHasProperFormat(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Check DSN starts with amqp://
+        $this->assertStringContainsString(
+            "'dsn' => 'amqp://'",
+            $content,
+            'RabbitMQ DSN must start with amqp://'
+        );
+    }
+
+    /**
+     * Test RabbitMQ has fallback for empty credentials
+     */
+    public function testRabbitMqHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Check that getenv has ?: '' fallback
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'RABBIT_USER'\s*\)\s*\?:\s*''/",
+            $content,
+            'RABBIT_USER must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'RABBIT_PASSWORD'\s*\)\s*\?:\s*''/",
+            $content,
+            'RABBIT_PASSWORD must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test cookie validation key has fallback for local development
+     */
+    public function testCookieKeyHasFallback(): void
+    {
+        $content = file_get_contents($this->configPath);
+
+        // Check that cookieValidationKey has a fallback for local dev
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'COOKIE_VALIDATION_KEY'\s*\)\s*\?:/",
+            $content,
+            'COOKIE_VALIDATION_KEY must have fallback for local development'
+        );
+    }
+
+    /**
+     * Test config is valid PHP syntax
+     */
+    public function testConfigIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->configPath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'media.config.php must have valid PHP syntax: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test config returns array
+     */
+    public function testConfigReturnsArray(): void
+    {
+        // Set required env vars to prevent errors
+        putenv('RABBIT_USER=test');
+        putenv('RABBIT_PASSWORD=test');
+        putenv('RABBIT_HOST=localhost');
+        putenv('COOKIE_VALIDATION_KEY=test-key');
+
+        $config = require $this->configPath;
+
+        $this->assertIsArray($config, 'media.config.php must return array');
+        $this->assertArrayHasKey('components', $config, 'Config must have components key');
+
+        // Clean up env
+        putenv('RABBIT_USER');
+        putenv('RABBIT_PASSWORD');
+        putenv('RABBIT_HOST');
+        putenv('COOKIE_VALIDATION_KEY');
+    }
+}
diff --git a/erp24/tests/unit/config/NoHardcodedSecretsTest.php b/erp24/tests/unit/config/NoHardcodedSecretsTest.php
new file mode 100644 (file)
index 0000000..3d060fc
--- /dev/null
@@ -0,0 +1,356 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Acceptance tests: No hardcoded secrets in configuration files
+ * Verifies that all secrets are moved to .env and use getenv()
+ *
+ * @group config
+ * @group security
+ * @group acceptance
+ */
+class NoHardcodedSecretsTest extends Unit
+{
+    /**
+     * @var array Regex patterns to detect hardcoded secrets
+     * SECURITY: These are detection patterns, NOT actual secrets
+     */
+    private array $forbiddenRegexPatterns = [
+        '/\d{10}:AA[A-Za-z0-9_-]{33,35}/',                  // Telegram bot token format
+        '/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/', // UUID format (potential API keys)
+        '/amqp:\/\/[a-zA-Z0-9_]+:[^@]+@/',                  // RabbitMQ credentials in DSN
+        '/pk_[a-f0-9]{30,}/',                               // CloudPayments public key
+    ];
+
+    /**
+     * @var array Known placeholder/example values that are OK
+     */
+    private array $allowedPlaceholders = [
+        'dev_password_change_me',
+        'dev_rabbit_password',
+        'dev_cookie_key_32chars_minimum!!',
+        '000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+    ];
+
+    /**
+     * @var array Configuration files to check
+     */
+    private array $configFiles = [
+        'config/params.php',
+        'config/web.php',
+        'config/console.php',
+        'config/db.php',
+        'config/db2.php',
+        'config/dev.console.config.php',
+        'config/prod.console.config.php',
+        'api2/config/api2.config.php',
+    ];
+
+    /**
+     * @var array Service files that should use getenv()
+     */
+    private array $serviceFiles = [
+        'services/TelegramService.php',
+        'services/MarketplaceService.php',
+        'records/LPTrackerApi.php',
+        'inc/cloudpayments.php',
+        'modul/api/cloudpayments.php',
+        'modul/collation/cloudpayments.php',
+        'modul/api/greensms.php',
+        'modul/bonus/bonusplus_api.php',
+    ];
+
+    /**
+     * Helper method to check content for forbidden regex patterns
+     */
+    private function assertNoForbiddenPatterns(string $content, string $fileName): void
+    {
+        foreach ($this->forbiddenRegexPatterns as $pattern) {
+            // Find all matches
+            preg_match_all($pattern, $content, $matches);
+
+            // Filter out allowed placeholders
+            $realSecrets = array_filter($matches[0] ?? [], function ($match) {
+                foreach ($this->allowedPlaceholders as $placeholder) {
+                    if (strpos($match, $placeholder) !== false) {
+                        return false;
+                    }
+                }
+                return true;
+            });
+
+            $this->assertEmpty(
+                $realSecrets,
+                "Found potential hardcoded secrets in $fileName matching pattern $pattern: " . implode(', ', $realSecrets)
+            );
+        }
+    }
+
+    /**
+     * Check params.php for hardcoded secrets
+     */
+    public function testParamsPhpNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/config/params.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertNoForbiddenPatterns($content, 'params.php');
+
+        // Check that getenv() is used
+        $this->assertStringContainsString('getenv(', $content, 'params.php should use getenv()');
+    }
+
+    /**
+     * Check web.php for hardcoded secrets
+     */
+    public function testWebPhpNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/config/web.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertNoForbiddenPatterns($content, 'web.php');
+
+        // Check that cookieValidationKey uses getenv()
+        $this->assertStringContainsString(
+            "getenv('COOKIE_VALIDATION_KEY')",
+            $content,
+            'cookieValidationKey should use getenv()'
+        );
+    }
+
+    /**
+     * Check console.php for hardcoded secrets
+     */
+    public function testConsolePhpNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/config/console.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertNoForbiddenPatterns($content, 'console.php');
+    }
+
+    /**
+     * Check db.php for hardcoded passwords in fallback
+     */
+    public function testDbPhpNoHardcodedPasswords(): void
+    {
+        $file = \Yii::getAlias('@app/config/db.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        // Check that fallback values are empty
+        $this->assertStringContainsString(
+            "getenv('POSTGRES_PASSWORD') ?: ''",
+            $content,
+            'POSTGRES_PASSWORD should have empty fallback'
+        );
+
+        $this->assertStringContainsString(
+            "getenv('DB_PASSWORD') ?: ''",
+            $content,
+            'DB_PASSWORD should have empty fallback'
+        );
+    }
+
+    /**
+     * Check db2.php for hardcoded passwords in fallback
+     */
+    public function testDb2PhpNoHardcodedPasswords(): void
+    {
+        $file = \Yii::getAlias('@app/config/db2.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertStringContainsString(
+            "getenv('DB_PASSWORD') ?: ''",
+            $content,
+            'DB_PASSWORD should have empty fallback in db2.php'
+        );
+    }
+
+    /**
+     * Check dev.console.config.php for hardcoded secrets
+     */
+    public function testDevConsoleConfigNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/config/dev.console.config.php');
+        if (!file_exists($file)) {
+            $this->markTestSkipped('File dev.console.config.php does not exist');
+        }
+
+        $content = file_get_contents($file);
+        $this->assertNoForbiddenPatterns($content, 'dev.console.config.php');
+    }
+
+    /**
+     * Check prod.console.config.php for hardcoded secrets
+     */
+    public function testProdConsoleConfigNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/config/prod.console.config.php');
+        if (!file_exists($file)) {
+            $this->markTestSkipped('File prod.console.config.php does not exist');
+        }
+
+        $content = file_get_contents($file);
+        $this->assertNoForbiddenPatterns($content, 'prod.console.config.php');
+    }
+
+    /**
+     * Check api2.config.php for hardcoded secrets
+     */
+    public function testApi2ConfigNoHardcodedSecrets(): void
+    {
+        $file = \Yii::getAlias('@app/api2/config/api2.config.php');
+        if (!file_exists($file)) {
+            $this->markTestSkipped('File api2.config.php does not exist');
+        }
+
+        $content = file_get_contents($file);
+        $this->assertNoForbiddenPatterns($content, 'api2.config.php');
+    }
+
+    /**
+     * Comprehensive check of all config files for secrets
+     */
+    public function testAllConfigFilesNoHardcodedSecrets(): void
+    {
+        $basePath = \Yii::getAlias('@app');
+
+        foreach ($this->configFiles as $relPath) {
+            $file = $basePath . '/' . $relPath;
+            if (!file_exists($file)) {
+                continue;
+            }
+
+            $content = file_get_contents($file);
+            $this->assertNoForbiddenPatterns($content, $relPath);
+        }
+    }
+
+    /**
+     * Check TelegramService uses getenv for all secrets
+     */
+    public function testTelegramServiceUsesGetenv(): void
+    {
+        $file = \Yii::getAlias('@app/services/TelegramService.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $requiredEnvVars = [
+            'TELEGRAM_BOT_TOKEN',
+            'TELEGRAM_BOT_TOKEN_PROD',
+            'TELEGRAM_CHAT_CHANNEL_ID',
+            'TELEGRAM_CHAT_CHANNEL_ERP_ID',
+            'CHATBOT_SALT',
+        ];
+
+        foreach ($requiredEnvVars as $var) {
+            $this->assertStringContainsString(
+                "getenv('$var')",
+                $content,
+                "TelegramService.php should use getenv('$var')"
+            );
+        }
+    }
+
+    /**
+     * Check LPTrackerApi uses getenv for credentials
+     */
+    public function testLpTrackerApiUsesGetenv(): void
+    {
+        $file = \Yii::getAlias('@app/records/LPTrackerApi.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertStringContainsString(
+            "getenv('LPTRACKER_LOGIN')",
+            $content,
+            "LPTrackerApi.php should use getenv('LPTRACKER_LOGIN')"
+        );
+
+        $this->assertStringContainsString(
+            "getenv('LPTRACKER_PASSWORD')",
+            $content,
+            "LPTrackerApi.php should use getenv('LPTRACKER_PASSWORD')"
+        );
+    }
+
+    /**
+     * Check CloudPayments files use getenv for credentials
+     */
+    public function testCloudPaymentsFilesUseGetenv(): void
+    {
+        $basePath = \Yii::getAlias('@app');
+        $files = [
+            'inc/cloudpayments.php',
+            'modul/api/cloudpayments.php',
+            'modul/collation/cloudpayments.php',
+        ];
+
+        foreach ($files as $relPath) {
+            $file = $basePath . '/' . $relPath;
+            if (!file_exists($file)) {
+                continue;
+            }
+
+            $content = file_get_contents($file);
+            $this->assertStringContainsString(
+                "getenv('CLOUDPAYMENTS_PUBLIC_ID')",
+                $content,
+                "$relPath should use getenv('CLOUDPAYMENTS_PUBLIC_ID')"
+            );
+        }
+    }
+
+    /**
+     * Check MarketplaceService uses getenv for email credentials
+     */
+    public function testMarketplaceServiceUsesGetenv(): void
+    {
+        $file = \Yii::getAlias('@app/services/MarketplaceService.php');
+        $this->assertFileExists($file);
+        $content = file_get_contents($file);
+
+        $this->assertStringContainsString(
+            "getenv('EMAIL_ZAKAZ_PASSWORD')",
+            $content,
+            "MarketplaceService.php should use getenv('EMAIL_ZAKAZ_PASSWORD')"
+        );
+
+        $this->assertStringContainsString(
+            "getenv('EMAIL_FLOW_PASSWORD')",
+            $content,
+            "MarketplaceService.php should use getenv('EMAIL_FLOW_PASSWORD')"
+        );
+    }
+
+    /**
+     * Check all service files use getenv pattern
+     */
+    public function testAllServiceFilesUseGetenv(): void
+    {
+        $basePath = \Yii::getAlias('@app');
+        $errors = [];
+
+        foreach ($this->serviceFiles as $relPath) {
+            $file = $basePath . '/' . $relPath;
+            if (!file_exists($file)) {
+                continue;
+            }
+
+            $content = file_get_contents($file);
+            if (strpos($content, 'getenv(') === false) {
+                $errors[] = "$relPath does not use getenv()";
+            }
+        }
+
+        $this->assertEmpty($errors, "Files not using getenv():\n" . implode("\n", $errors));
+    }
+}
diff --git a/erp24/tests/unit/config/ParamsConfigTest.php b/erp24/tests/unit/config/ParamsConfigTest.php
new file mode 100644 (file)
index 0000000..23644df
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit tests for application parameters (params.php)
+ * Verifies correct loading of params from .env
+ *
+ * @group config
+ * @group params
+ * @group acceptance
+ */
+class ParamsConfigTest extends Unit
+{
+    /**
+     * Verifies that params are loaded
+     */
+    public function testParamsLoaded(): void
+    {
+        $params = \Yii::$app->params;
+
+        $this->assertIsArray($params);
+        $this->assertNotEmpty($params);
+    }
+
+    /**
+     * Verifies TELEGRAM_API_URL configuration
+     */
+    public function testTelegramApiUrlConfigured(): void
+    {
+        $telegramUrl = \Yii::$app->params['TELEGRAM_API_URL'] ?? null;
+
+        $this->assertNotNull($telegramUrl, 'TELEGRAM_API_URL must be configured');
+        $this->assertStringStartsWith(
+            'https://api.telegram.org/bot',
+            $telegramUrl,
+            'TELEGRAM_API_URL must start with https://api.telegram.org/bot'
+        );
+
+        // Verify token is loaded from .env
+        $token = getenv('TELEGRAM_BOT_TOKEN');
+        if ($token) {
+            $this->assertStringContainsString(
+                $token,
+                $telegramUrl,
+                'TELEGRAM_API_URL must contain token from TELEGRAM_BOT_TOKEN'
+            );
+        }
+    }
+
+    /**
+     * Verifies WHATSAPP_API_KEY configuration (optional)
+     * Key may be empty in test/dev environments
+     */
+    public function testWhatsAppApiKeyConfigured(): void
+    {
+        $apiKey = \Yii::$app->params['WHATSAPP_API_KEY'] ?? null;
+
+        // Key must exist in params (but can be empty)
+        $this->assertArrayHasKey('WHATSAPP_API_KEY', \Yii::$app->params, 'WHATSAPP_API_KEY must be defined in params');
+
+        // If env var is set, verify it matches params
+        $envKey = getenv('WHATSAPP_API_KEY');
+        if ($envKey) {
+            $this->assertEquals($envKey, $apiKey, 'WHATSAPP_API_KEY must match env variable');
+        }
+    }
+
+    /**
+     * Verifies YANDEX_MARKET_API_KEY configuration
+     */
+    public function testYandexMarketApiKeyConfigured(): void
+    {
+        $apiKey = \Yii::$app->params['YANDEX_MARKET_API_KEY'] ?? null;
+
+        $this->assertNotNull($apiKey, 'YANDEX_MARKET_API_KEY must be configured');
+
+        // Verify it matches env var if set
+        $envKey = getenv('YANDEX_MARKET_API_KEY');
+        if ($envKey) {
+            $this->assertEquals($envKey, $apiKey, 'YANDEX_MARKET_API_KEY must match env variable');
+        }
+    }
+
+    /**
+     * Verifies SWITCH_USER_COOKIE_PASSWORD configuration
+     */
+    public function testSwitchUserPasswordConfigured(): void
+    {
+        $password = \Yii::$app->params['SWITCH_USER_COOKIE_PASSWORD'] ?? null;
+
+        $this->assertNotNull($password, 'SWITCH_USER_COOKIE_PASSWORD must be configured');
+
+        // Verify it matches env var if set
+        $envPassword = getenv('SWITCH_USER_COOKIE_PASSWORD');
+        if ($envPassword) {
+            $this->assertEquals($envPassword, $password, 'SWITCH_USER_COOKIE_PASSWORD must match env variable');
+        }
+    }
+
+    /**
+     * Verifies CAMERAS configuration structure (optional values)
+     * Cameras may have empty credentials in test/dev environments
+     */
+    public function testCamerasConfigured(): void
+    {
+        $cameras = \Yii::$app->params['CAMERAS'] ?? null;
+
+        $this->assertNotNull($cameras, 'CAMERAS must be configured');
+        $this->assertIsArray($cameras, 'CAMERAS must be an array');
+        $this->assertCount(5, $cameras, 'Must have 5 cameras');
+
+        foreach ($cameras as $index => $camera) {
+            // Structure must exist
+            $this->assertArrayHasKey('Login', $camera, "Camera $index must have Login key");
+            $this->assertArrayHasKey('Password', $camera, "Camera $index must have Password key");
+            // Values can be empty in test environment - only check structure
+        }
+
+        // If env vars are set, verify they match
+        $camera1Login = getenv('CAMERA_1_LOGIN');
+        if ($camera1Login) {
+            $this->assertEquals($camera1Login, $cameras[0]['Login'], 'Camera 1 Login must match CAMERA_1_LOGIN env var');
+        }
+    }
+
+    /**
+     * Verifies RABBIT_HOST configuration
+     */
+    public function testRabbitHostConfigured(): void
+    {
+        $rabbitHost = \Yii::$app->params['RABBIT_HOST'] ?? null;
+
+        $this->assertNotNull($rabbitHost, 'RABBIT_HOST must be configured in params');
+
+        // Verify it matches env var if set
+        $envHost = getenv('RABBIT_HOST');
+        if ($envHost) {
+            $this->assertEquals($envHost, $rabbitHost, 'RABBIT_HOST must match env variable');
+        }
+    }
+
+    /**
+     * Verifies API2_URL configuration
+     */
+    public function testApi2UrlConfigured(): void
+    {
+        $api2Url = \Yii::$app->params['API2_URL'] ?? null;
+
+        $this->assertNotNull($api2Url, 'API2_URL must be configured');
+        $this->assertStringStartsWith('http', $api2Url, 'API2_URL must start with http');
+    }
+
+    /**
+     * Verifies TELEGRAM_WEBHOOK_URL configuration
+     */
+    public function testTelegramWebhookUrlConfigured(): void
+    {
+        $webhookUrl = \Yii::$app->params['TELEGRAM_WEBHOOK_URL'] ?? null;
+
+        $this->assertNotNull($webhookUrl, 'TELEGRAM_WEBHOOK_URL must be configured');
+        $this->assertStringStartsWith('https://', $webhookUrl, 'TELEGRAM_WEBHOOK_URL must start with https://');
+        $this->assertStringContainsString('/telegram/webhook', $webhookUrl, 'TELEGRAM_WEBHOOK_URL must contain /telegram/webhook');
+    }
+}
diff --git a/erp24/tests/unit/config/RabbitMqConfigTest.php b/erp24/tests/unit/config/RabbitMqConfigTest.php
new file mode 100644 (file)
index 0000000..fbd54f8
--- /dev/null
@@ -0,0 +1,169 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Unit tests for RabbitMQ configuration
+ * Verifies correct DSN building with URL encoding for special characters
+ *
+ * @group config
+ * @group rabbitmq
+ * @group acceptance
+ */
+class RabbitMqConfigTest extends Unit
+{
+    /**
+     * Test URL encoding of RabbitMQ credentials with special characters
+     *
+     * Covers: web.php, console.php, dev.console.config.php, prod.console.config.php, api2.config.php
+     * Change: DSN now uses rawurlencode() for username and password
+     */
+    public function testRabbitMqDsnUrlEncodesCredentials(): void
+    {
+        $testCases = [
+            // Simple credentials
+            ['user' => 'admin', 'password' => 'password123', 'host' => 'localhost'],
+            // Credentials with @ symbol
+            ['user' => 'admin', 'password' => 'pass@word', 'host' => 'rabbitmq'],
+            // Credentials with : symbol
+            ['user' => 'admin', 'password' => 'pass:word', 'host' => 'rabbitmq'],
+            // Credentials with / symbol
+            ['user' => 'admin', 'password' => 'pass/word', 'host' => 'rabbitmq'],
+            // Credentials with multiple special characters
+            ['user' => 'admin', 'password' => 'p@ss:w/rd!#$', 'host' => 'localhost'],
+            // Empty credentials (edge case)
+            ['user' => '', 'password' => '', 'host' => 'localhost'],
+        ];
+
+        foreach ($testCases as $index => $case) {
+            $dsn = $this->buildRabbitMqDsn($case['user'], $case['password'], $case['host']);
+
+            // Verify DSN format is valid AMQP URL
+            $this->assertStringStartsWith('amqp://', $dsn, "Case $index: DSN must start with amqp://");
+
+            // Verify URL-encoded credentials don't break the URL structure
+            $this->assertStringContainsString('@', $dsn, "Case $index: DSN must contain @ separator");
+            $this->assertStringContainsString(':5672', $dsn, "Case $index: DSN must contain port");
+
+            // Verify special characters are URL-encoded
+            if (strpos($case['password'], '@') !== false) {
+                $this->assertStringNotContainsString('@@', $dsn, "Case $index: @ in password must be encoded");
+            }
+            if (strpos($case['password'], ':') !== false) {
+                // The password part should have encoded colons
+                $encodedPassword = rawurlencode($case['password']);
+                $this->assertStringContainsString($encodedPassword, $dsn, "Case $index: Password must be URL-encoded");
+            }
+        }
+    }
+
+    /**
+     * Test that DSN can be parsed correctly after URL encoding
+     */
+    public function testRabbitMqDsnCanBeParsed(): void
+    {
+        $user = 'admin';
+        $password = 'p@ss:w/rd!';
+        $host = 'rabbitmq-host';
+
+        $dsn = $this->buildRabbitMqDsn($user, $password, $host);
+
+        // Parse the DSN using parse_url
+        $parsed = parse_url($dsn);
+
+        $this->assertEquals('amqp', $parsed['scheme'], 'Scheme must be amqp');
+        $this->assertEquals($user, urldecode($parsed['user']), 'Username must be decodable');
+        $this->assertEquals($password, urldecode($parsed['pass']), 'Password must be decodable');
+        $this->assertEquals($host, $parsed['host'], 'Host must match');
+        $this->assertEquals(5672, $parsed['port'], 'Port must be 5672');
+    }
+
+    /**
+     * Test that empty credentials result in valid DSN
+     */
+    public function testRabbitMqDsnWithEmptyCredentials(): void
+    {
+        $dsn = $this->buildRabbitMqDsn('', '', 'localhost');
+
+        // Even with empty credentials, DSN should be syntactically valid
+        $this->assertStringStartsWith('amqp://', $dsn);
+        $this->assertStringContainsString(':@localhost:5672', $dsn);
+    }
+
+    /**
+     * Test RabbitMQ host from environment variable
+     */
+    public function testRabbitHostFromEnv(): void
+    {
+        $rabbitHost = getenv('RABBIT_HOST');
+
+        if ($rabbitHost === false) {
+            $this->markTestSkipped('RABBIT_HOST env var not set');
+        }
+
+        $this->assertNotEmpty($rabbitHost, 'RABBIT_HOST must not be empty');
+
+        // Verify it's a valid hostname or container name
+        $this->assertMatchesRegularExpression(
+            '/^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$/',
+            $rabbitHost,
+            'RABBIT_HOST must be a valid hostname'
+        );
+    }
+
+    /**
+     * Test that queue component is configured in Yii app (if available)
+     */
+    public function testQueueComponentExists(): void
+    {
+        if (!\Yii::$app || !\Yii::$app->has('queue')) {
+            $this->markTestSkipped('Yii::$app->queue not initialized in test environment');
+        }
+
+        $queue = \Yii::$app->queue;
+        $this->assertNotNull($queue, 'Queue component must exist');
+    }
+
+    /**
+     * Test queue DSN uses environment variables
+     *
+     * @group integration
+     */
+    public function testQueueDsnUsesEnvVariables(): void
+    {
+        if (!\Yii::$app || !\Yii::$app->has('queue')) {
+            $this->markTestSkipped('Yii::$app->queue not initialized');
+        }
+
+        $queue = \Yii::$app->queue;
+        $dsn = $queue->dsn ?? null;
+
+        if ($dsn === null) {
+            $this->markTestSkipped('Queue DSN not accessible');
+        }
+
+        // Verify DSN format
+        $this->assertStringStartsWith('amqp://', $dsn, 'Queue DSN must use AMQP protocol');
+
+        // Check that credentials from env are used
+        $rabbitUser = getenv('RABBIT_USER');
+        if ($rabbitUser !== false && !empty($rabbitUser)) {
+            $this->assertStringContainsString(
+                rawurlencode($rabbitUser),
+                $dsn,
+                'Queue DSN must contain URL-encoded RABBIT_USER'
+            );
+        }
+    }
+
+    /**
+     * Helper method to build RabbitMQ DSN string
+     * Replicates the logic from config files
+     */
+    private function buildRabbitMqDsn(string $user, string $password, string $host): string
+    {
+        return 'amqp://' . rawurlencode($user) . ':' . rawurlencode($password) . '@' . $host . ':5672';
+    }
+}
diff --git a/erp24/tests/unit/config/StartupConfigSecretsTest.php b/erp24/tests/unit/config/StartupConfigSecretsTest.php
new file mode 100644 (file)
index 0000000..6a17065
--- /dev/null
@@ -0,0 +1,433 @@
+<?php
+
+namespace tests\unit\config;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for startup.php API token secrets migration
+ *
+ * Verifies that startup.php uses environment variables
+ * for API tokens instead of hardcoded values.
+ *
+ * Covers:
+ * - erp24/startup.php
+ * - erp24/modul/orders/delivery.php
+ *
+ * @group config
+ * @group api
+ * @group secrets
+ */
+class StartupConfigSecretsTest extends Unit
+{
+    private string $basePath;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+    }
+
+    // ============================================================
+    // startup.php tests
+    // ============================================================
+
+    /**
+     * Test startup.php exists
+     */
+    public function testStartupFileExists(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $this->assertFileExists($filePath, 'startup.php must exist');
+    }
+
+    /**
+     * Test startup.php uses API_TOKEN env
+     */
+    public function testStartupUsesApiTokenEnv(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]API_TOKEN[\'"]\s*\)/',
+            $content,
+            'startup.php must use getenv(\'API_TOKEN\')'
+        );
+    }
+
+    /**
+     * Test startup.php uses TOKEN_CLOUD env
+     */
+    public function testStartupUsesTokenCloudEnv(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TOKEN_CLOUD[\'"]\s*\)/',
+            $content,
+            'startup.php must use getenv(\'TOKEN_CLOUD\')'
+        );
+    }
+
+    /**
+     * Test startup.php uses AMO_SECRET_PHRASE env
+     */
+    public function testStartupUsesAmoSecretPhraseEnv(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SECRET_PHRASE[\'"]\s*\)/',
+            $content,
+            'startup.php must use getenv(\'AMO_SECRET_PHRASE\')'
+        );
+    }
+
+    /**
+     * Test startup.php has no hardcoded API token
+     */
+    public function testStartupNoHardcodedApiToken(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked token pattern
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"]getJH6GFi4tpU84YVPW9M/',
+            $content,
+            'startup.php must not contain hardcoded API token'
+        );
+    }
+
+    /**
+     * Test startup.php has no hardcoded TOKEN_CLOUD
+     */
+    public function testStartupNoHardcodedTokenCloud(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/[\'"]iC04295J9HyD2H3GJF3btky[\'"]/',
+            $content,
+            'startup.php must not contain hardcoded TOKEN_CLOUD'
+        );
+    }
+
+    /**
+     * Test startup.php has PHP doc
+     */
+    public function testStartupHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/startup.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'startup.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // delivery.php tests
+    // ============================================================
+
+    /**
+     * Test delivery.php exists
+     */
+    public function testDeliveryFileExists(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $this->assertFileExists($filePath, 'modul/orders/delivery.php must exist');
+    }
+
+    /**
+     * Test delivery.php uses API_TOKEN env
+     */
+    public function testDeliveryUsesApiTokenEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]API_TOKEN[\'"]\s*\)/',
+            $content,
+            'delivery.php must use getenv(\'API_TOKEN\')'
+        );
+    }
+
+    /**
+     * Test delivery.php uses TOKEN_CLOUD env
+     */
+    public function testDeliveryUsesTokenCloudEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TOKEN_CLOUD[\'"]\s*\)/',
+            $content,
+            'delivery.php must use getenv(\'TOKEN_CLOUD\')'
+        );
+    }
+
+    /**
+     * Test delivery.php uses AMO_SECRET_PHRASE env
+     */
+    public function testDeliveryUsesAmoSecretPhraseEnv(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]AMO_SECRET_PHRASE[\'"]\s*\)/',
+            $content,
+            'delivery.php must use getenv(\'AMO_SECRET_PHRASE\')'
+        );
+    }
+
+    /**
+     * Test delivery.php has no hardcoded tokens in URL
+     */
+    public function testDeliveryNoHardcodedTokensInUrl(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        // Check that URL doesn't contain hardcoded tokens
+        $this->assertDoesNotMatchRegularExpression(
+            '/key=getJH6GFi4tpU84YVPW9M/',
+            $content,
+            'delivery.php must not contain hardcoded API key in URL'
+        );
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/token_cloud=iC04295J9HyD2H3GJF3btky/',
+            $content,
+            'delivery.php must not contain hardcoded TOKEN_CLOUD in URL'
+        );
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/secret_phrase=VJJVkt467ltuXU/',
+            $content,
+            'delivery.php must not contain hardcoded secret_phrase in URL'
+        );
+    }
+
+    /**
+     * Test delivery.php uses urlencode for tokens
+     */
+    public function testDeliveryUsesUrlEncodeForTokens(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'urlencode($apiToken)',
+            $content,
+            'delivery.php must use urlencode for API token'
+        );
+    }
+
+    /**
+     * Test delivery.php has PHP doc
+     */
+    public function testDeliveryHasPhpDoc(): void
+    {
+        $filePath = $this->basePath . '/modul/orders/delivery.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'delivery.php must have documentation comment'
+        );
+    }
+
+    // ============================================================
+    // inc/db.php tests
+    // ============================================================
+
+    /**
+     * Test inc/db.php has no hardcoded passwords
+     */
+    public function testIncDbNoHardcodedPasswords(): void
+    {
+        $filePath = $this->basePath . '/inc/db.php';
+        $content = file_get_contents($filePath);
+
+        // Known leaked passwords
+        $leakedPasswords = [
+            'H7s0Y1v0',
+            'I0v6Y3p8',
+            'JVJruro_Xdg456o3ir',
+        ];
+
+        foreach ($leakedPasswords as $password) {
+            $this->assertStringNotContainsString(
+                $password,
+                $content,
+                "inc/db.php must not contain leaked password: {$password}"
+            );
+        }
+    }
+
+    /**
+     * Test inc/db.php has no commented out credentials
+     */
+    public function testIncDbNoCommentedCredentials(): void
+    {
+        $filePath = $this->basePath . '/inc/db.php';
+        $content = file_get_contents($filePath);
+
+        // Should not have define('DB_PASS', ...) even in comments
+        $this->assertDoesNotMatchRegularExpression(
+            '/define\s*\(\s*[\'"]DB_PASS[\'"]\s*,/',
+            $content,
+            'inc/db.php must not contain DB_PASS define (even in comments)'
+        );
+    }
+
+    /**
+     * Test inc/db.php uses POSTGRES_PASSWORD env
+     */
+    public function testIncDbUsesPostgresPasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/db.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]POSTGRES_PASSWORD[\'"]\s*\)/',
+            $content,
+            'inc/db.php must use getenv(\'POSTGRES_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test inc/db.php has empty fallback for password
+     */
+    public function testIncDbHasEmptyPasswordFallback(): void
+    {
+        $filePath = $this->basePath . '/inc/db.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]POSTGRES_PASSWORD[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $content,
+            'POSTGRES_PASSWORD getenv must have empty fallback'
+        );
+    }
+
+    /**
+     * Test inc/db.php has conditional initialization
+     */
+    public function testIncDbHasConditionalInit(): void
+    {
+        $filePath = $this->basePath . '/inc/db.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/if\s*\(\s*!empty\s*\(\s*\$config\[.DB_USER.\]\s*\)/',
+            $content,
+            'inc/db.php must check for DB_USER before initializing'
+        );
+    }
+
+    // ============================================================
+    // crmconf.php tests
+    // ============================================================
+
+    /**
+     * Test crmconf.php uses SHOP_DELETE_PASSWORD env
+     */
+    public function testCrmconfUsesShopDeletePasswordEnv(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]SHOP_DELETE_PASSWORD[\'"]\s*\)/',
+            $content,
+            'crmconf.php must use getenv(\'SHOP_DELETE_PASSWORD\')'
+        );
+    }
+
+    /**
+     * Test crmconf.php has no hardcoded pass_dell_shop
+     */
+    public function testCrmconfNoHardcodedPassDellShop(): void
+    {
+        $filePath = $this->basePath . '/inc/crmconf.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/\[.pass_dell_shop.\]\s*=\s*["\']Olidoell341["\']/',
+            $content,
+            'crmconf.php must not contain hardcoded pass_dell_shop'
+        );
+    }
+
+    // ============================================================
+    // .env.example tests
+    // ============================================================
+
+    /**
+     * Test .env.example has SHOP_DELETE_PASSWORD
+     */
+    public function testEnvExampleHasShopDeletePassword(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'SHOP_DELETE_PASSWORD=',
+            $envExample,
+            '.env.example must contain SHOP_DELETE_PASSWORD'
+        );
+    }
+
+    /**
+     * Test .env.example has API_TOKEN
+     */
+    public function testEnvExampleHasApiToken(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'API_TOKEN=',
+            $envExample,
+            '.env.example must contain API_TOKEN'
+        );
+    }
+
+    /**
+     * Test .env.example has TOKEN_CLOUD
+     */
+    public function testEnvExampleHasTokenCloud(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'TOKEN_CLOUD=',
+            $envExample,
+            '.env.example must contain TOKEN_CLOUD'
+        );
+    }
+
+    /**
+     * Test no known leaked tokens in api_text.php documentation
+     */
+    public function testApiTextNoLeakedTokens(): void
+    {
+        $filePath = $this->basePath . '/modul/api/api_text.php';
+        $content = file_get_contents($filePath);
+
+        $this->assertDoesNotMatchRegularExpression(
+            '/getJH6GFi4tpU84YVPW9M/',
+            $content,
+            'api_text.php must not contain leaked API token in documentation'
+        );
+    }
+}
diff --git a/erp24/tests/unit/controllers/TelegramControllerConfigTest.php b/erp24/tests/unit/controllers/TelegramControllerConfigTest.php
new file mode 100644 (file)
index 0000000..df951b3
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for TelegramController getenv() migration
+ *
+ * Verifies that TelegramController uses environment variables
+ * for Telegram bot token instead of hardcoded values.
+ *
+ * Covers changes in:
+ * - erp24/api2/controllers/TelegramController.php
+ *
+ * @group config
+ * @group telegram
+ * @group acceptance
+ */
+class TelegramControllerConfigTest extends Unit
+{
+    private string $controllerPath;
+
+    protected function _before(): void
+    {
+        $this->controllerPath = dirname(__DIR__, 3) . '/api2/controllers/TelegramController.php';
+    }
+
+    /**
+     * Test TelegramController file exists
+     */
+    public function testControllerFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->controllerPath,
+            'TelegramController.php must exist'
+        );
+    }
+
+    /**
+     * Test TelegramController uses TELEGRAM_BOT_TOKEN from env
+     */
+    public function testControllerUsesEnvToken(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_BOT_TOKEN')",
+            $content,
+            'TelegramController must use TELEGRAM_BOT_TOKEN from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded Telegram tokens in controller
+     *
+     * Telegram bot tokens have format: 123456789:ABCdefGHI_jklMNOpqrsTUVwxyz12345
+     * We check that no such patterns exist in the code
+     */
+    public function testNoHardcodedTokens(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        // Check for old hardcoded token patterns (9+ digits : 35 alphanumeric chars)
+        $this->assertDoesNotMatchRegularExpression(
+            '/\d{9,}:[A-Za-z0-9_-]{35}/',
+            $content,
+            'TelegramController must not contain hardcoded bot tokens'
+        );
+    }
+
+    /**
+     * Test static $API_URL property is removed
+     *
+     * Old implementation had: private static $API_URL = "https://api.telegram.org/bot...";
+     * New implementation uses getApiUrl() method
+     */
+    public function testNoStaticApiUrlProperty(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringNotContainsString(
+            'private static $API_URL',
+            $content,
+            'Static $API_URL property should be removed (use getApiUrl() method instead)'
+        );
+    }
+
+    /**
+     * Test getApiUrl() method exists
+     */
+    public function testGetApiUrlMethodExists(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'private static function getApiUrl()',
+            $content,
+            'getApiUrl() method must exist'
+        );
+    }
+
+    /**
+     * Test getApiUrl() returns correct Telegram API base URL
+     */
+    public function testGetApiUrlReturnsCorrectBaseUrl(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            "return 'https://api.telegram.org/bot'",
+            $content,
+            'getApiUrl() must return Telegram API URL base'
+        );
+    }
+
+    /**
+     * Test init() method exists and initializes botToken from env
+     */
+    public function testInitMethodSetsBotToken(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'public function init()',
+            $content,
+            'init() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            "\$this->botToken = getenv('TELEGRAM_BOT_TOKEN')",
+            $content,
+            'init() must set botToken from env'
+        );
+    }
+
+    /**
+     * Test botToken property is declared
+     */
+    public function testBotTokenPropertyExists(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'public string $botToken',
+            $content,
+            'botToken property must be declared as public string'
+        );
+    }
+
+    /**
+     * Test controller doesn't contain old hardcoded token values
+     */
+    public function testNoKnownHardcodedTokenPatterns(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        // Check for specific known old token prefixes (masked)
+        $oldTokenPatterns = [
+            '8063257458:', // old dev token prefix
+            '5456741805:', // old prod token prefix
+        ];
+
+        foreach ($oldTokenPatterns as $pattern) {
+            $this->assertStringNotContainsString(
+                $pattern,
+                $content,
+                "Controller must not contain old hardcoded token pattern: $pattern"
+            );
+        }
+    }
+}
diff --git a/erp24/tests/unit/controllers/TelegramSalebotControllerConfigTest.php b/erp24/tests/unit/controllers/TelegramSalebotControllerConfigTest.php
new file mode 100644 (file)
index 0000000..747eed0
--- /dev/null
@@ -0,0 +1,199 @@
+<?php
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for TelegramSalebotController getenv() migration
+ *
+ * Verifies that TelegramSalebotController uses environment variables
+ * for Telegram Salebot token instead of hardcoded values.
+ *
+ * Covers changes in:
+ * - erp24/api2/controllers/TelegramSalebotController.php
+ *
+ * @group config
+ * @group telegram
+ * @group acceptance
+ */
+class TelegramSalebotControllerConfigTest extends Unit
+{
+    private string $controllerPath;
+
+    protected function _before(): void
+    {
+        $this->controllerPath = dirname(__DIR__, 3) . '/api2/controllers/TelegramSalebotController.php';
+    }
+
+    /**
+     * Test TelegramSalebotController file exists
+     */
+    public function testControllerFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->controllerPath,
+            'TelegramSalebotController.php must exist'
+        );
+    }
+
+    /**
+     * Test controller uses TELEGRAM_BOT_TOKEN_SALEBOT from env
+     */
+    public function testControllerUsesEnvToken(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_BOT_TOKEN_SALEBOT')",
+            $content,
+            'TelegramSalebotController must use TELEGRAM_BOT_TOKEN_SALEBOT from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded Telegram tokens in controller
+     *
+     * Telegram bot tokens have format: 123456789:ABCdefGHI_jklMNOpqrsTUVwxyz12345
+     * We check that no such patterns exist in the code
+     */
+    public function testNoHardcodedTokens(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        // Check for hardcoded token patterns (9+ digits : 35 alphanumeric chars)
+        $this->assertDoesNotMatchRegularExpression(
+            '/\d{9,}:[A-Za-z0-9_-]{35}/',
+            $content,
+            'TelegramSalebotController must not contain hardcoded bot tokens'
+        );
+    }
+
+    /**
+     * Test static $TOKEN property is removed
+     *
+     * Old implementation had: private static $TOKEN = "...";
+     * New implementation uses getToken() method
+     */
+    public function testNoStaticTokenProperty(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringNotContainsString(
+            'private static $TOKEN',
+            $content,
+            'Static $TOKEN property should be removed (use getToken() method instead)'
+        );
+    }
+
+    /**
+     * Test static $API_URL property is removed
+     *
+     * Old implementation had: private static $API_URL = "https://api.telegram.org/bot...";
+     * New implementation uses getApiUrl() method
+     */
+    public function testNoStaticApiUrlProperty(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringNotContainsString(
+            'private static $API_URL',
+            $content,
+            'Static $API_URL property should be removed (use getApiUrl() method instead)'
+        );
+    }
+
+    /**
+     * Test getToken() method exists
+     */
+    public function testGetTokenMethodExists(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'private static function getToken()',
+            $content,
+            'getToken() method must exist'
+        );
+    }
+
+    /**
+     * Test getApiUrl() method exists
+     */
+    public function testGetApiUrlMethodExists(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'private static function getApiUrl()',
+            $content,
+            'getApiUrl() method must exist'
+        );
+    }
+
+    /**
+     * Test getApiUrl() constructs URL using getToken()
+     */
+    public function testGetApiUrlUsesGetToken(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            'self::getToken()',
+            $content,
+            'getApiUrl() must use self::getToken() to construct URL'
+        );
+    }
+
+    /**
+     * Test getApiUrl() returns correct Telegram API base URL
+     */
+    public function testGetApiUrlReturnsCorrectBaseUrl(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        $this->assertStringContainsString(
+            "'https://api.telegram.org/bot'",
+            $content,
+            'getApiUrl() must use Telegram API base URL'
+        );
+    }
+
+    /**
+     * Test controller doesn't contain old hardcoded token values
+     */
+    public function testNoKnownHardcodedTokenPatterns(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        // Check for specific known old token prefixes (masked for security)
+        $oldTokenPatterns = [
+            '8063257458:', // old dev token prefix
+            '5456741805:', // old prod token prefix
+            '7654321098:', // other known patterns
+        ];
+
+        foreach ($oldTokenPatterns as $pattern) {
+            $this->assertStringNotContainsString(
+                $pattern,
+                $content,
+                "Controller must not contain old hardcoded token pattern: $pattern"
+            );
+        }
+    }
+
+    /**
+     * Test getToken() returns empty string as fallback (not null or throws)
+     */
+    public function testGetTokenHasEmptyStringFallback(): void
+    {
+        $content = file_get_contents($this->controllerPath);
+
+        // Check that getToken has ?: '' fallback for missing env var
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'TELEGRAM_BOT_TOKEN_SALEBOT'\s*\)\s*\?:\s*''/",
+            $content,
+            'getToken() must have empty string fallback for missing env var'
+        );
+    }
+}
diff --git a/erp24/tests/unit/models/UsersFilterTelegramUsersForSendingTest.php b/erp24/tests/unit/models/UsersFilterTelegramUsersForSendingTest.php
new file mode 100644 (file)
index 0000000..f3baae7
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+namespace tests\unit\models;
+
+use tests\fixtures\KogortStopListFixture;
+use yii_app\records\Users;
+
+class UsersFilterTelegramUsersForSendingTest extends \Codeception\Test\Unit
+{
+    public function _fixtures()
+    {
+        return [
+            'kogortStopList' => KogortStopListFixture::class,
+        ];
+    }
+
+    public function testFilterTelegramUsersForSending()
+    {
+        $telegramUsers = [
+            ['phone' => '79990000001', 'chat_id' => 1], // в стоп-листе
+            ['phone' => '79990000002', 'chat_id' => 2], // в стоп-листе
+            ['phone' => '79990000003', 'chat_id' => 3], // не в стоп-листе
+            ['phone' => '79990000004', 'chat_id' => 4], // не в стоп-листе, но отправлен ранее
+        ];
+
+        $sentStatusKogort = ['79990000004'];
+
+        $filtered = Users::filterTelegramUsersForSending($telegramUsers, $sentStatusKogort);
+
+        $phones = array_values(array_map(function($u){ return $u['phone']; }, $filtered));
+
+        $this->assertEquals(['79990000003'], $phones);
+    }
+}
+
+
+
+
+
+
+
+
+
+
diff --git a/erp24/tests/unit/records/LPTrackerApiSecretsTest.php b/erp24/tests/unit/records/LPTrackerApiSecretsTest.php
new file mode 100644 (file)
index 0000000..63dae5a
--- /dev/null
@@ -0,0 +1,191 @@
+<?php
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for LPTrackerApi.php getenv() migration
+ *
+ * Verifies that LPTrackerApi uses environment variables
+ * for login and password credentials (2 secrets).
+ *
+ * Covers changes in:
+ * - erp24/records/LPTrackerApi.php
+ *
+ * ENV variables tested:
+ * - LPTRACKER_LOGIN
+ * - LPTRACKER_PASSWORD
+ *
+ * @group config
+ * @group lptracker
+ * @group secrets
+ */
+class LPTrackerApiSecretsTest extends Unit
+{
+    private string $recordPath;
+
+    protected function _before(): void
+    {
+        $this->recordPath = dirname(__DIR__, 3) . '/records/LPTrackerApi.php';
+    }
+
+    /**
+     * Test LPTrackerApi.php file exists
+     */
+    public function testRecordFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->recordPath,
+            'LPTrackerApi.php must exist'
+        );
+    }
+
+    /**
+     * Test config is valid PHP syntax
+     */
+    public function testRecordIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->recordPath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'LPTrackerApi.php must have valid PHP syntax: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test LPTRACKER_LOGIN uses getenv
+     */
+    public function testLoginUsesEnv(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertStringContainsString(
+            "getenv('LPTRACKER_LOGIN')",
+            $content,
+            'LPTrackerApi must use LPTRACKER_LOGIN from env'
+        );
+    }
+
+    /**
+     * Test LPTRACKER_PASSWORD uses getenv
+     */
+    public function testPasswordUsesEnv(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertStringContainsString(
+            "getenv('LPTRACKER_PASSWORD')",
+            $content,
+            'LPTrackerApi must use LPTRACKER_PASSWORD from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded LOGIN constant
+     */
+    public function testNoHardcodedLoginConstant(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        // Check that LOGIN is not a const with literal value
+        $this->assertDoesNotMatchRegularExpression(
+            '/const\s+LOGIN\s*=\s*[\'"][^\'"]+[\'"];/',
+            $content,
+            'Must not have hardcoded LOGIN constant'
+        );
+    }
+
+    /**
+     * Test no hardcoded PASSWORD constant
+     */
+    public function testNoHardcodedPasswordConstant(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        // Check that PASSWORD is not a const with literal value
+        $this->assertDoesNotMatchRegularExpression(
+            '/const\s+PASSWORD\s*=\s*[\'"][^\'"]+[\'"];/',
+            $content,
+            'Must not have hardcoded PASSWORD constant'
+        );
+    }
+
+    /**
+     * Test private getter methods exist
+     */
+    public function testPrivateGetterMethodsExist(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertStringContainsString(
+            'private static function getLogin()',
+            $content,
+            'getLogin() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            'private static function getPassword()',
+            $content,
+            'getPassword() method must exist'
+        );
+    }
+
+    /**
+     * Test getenv calls have empty string fallback
+     */
+    public function testGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'LPTRACKER_LOGIN'\s*\)\s*\?:\s*''/",
+            $content,
+            'LPTRACKER_LOGIN must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'LPTRACKER_PASSWORD'\s*\)\s*\?:\s*''/",
+            $content,
+            'LPTRACKER_PASSWORD must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test auth method uses getLogin and getPassword
+     */
+    public function testAuthUsesGetterMethods(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertStringContainsString(
+            'self::getLogin()',
+            $content,
+            'auth() must use self::getLogin()'
+        );
+
+        $this->assertStringContainsString(
+            'self::getPassword()',
+            $content,
+            'auth() must use self::getPassword()'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->recordPath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+    }
+}
diff --git a/erp24/tests/unit/services/MarketplaceServiceSecretsTest.php b/erp24/tests/unit/services/MarketplaceServiceSecretsTest.php
new file mode 100644 (file)
index 0000000..a5679d6
--- /dev/null
@@ -0,0 +1,180 @@
+<?php
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for MarketplaceService.php getenv() migration
+ *
+ * Verifies that MarketplaceService uses environment variables
+ * for email password credentials (2 secrets).
+ *
+ * Covers changes in:
+ * - erp24/services/MarketplaceService.php
+ *
+ * ENV variables tested:
+ * - EMAIL_ZAKAZ_PASSWORD
+ * - EMAIL_FLOW_PASSWORD
+ *
+ * @group config
+ * @group marketplace
+ * @group secrets
+ */
+class MarketplaceServiceSecretsTest extends Unit
+{
+    private string $servicePath;
+
+    protected function _before(): void
+    {
+        $this->servicePath = dirname(__DIR__, 3) . '/services/MarketplaceService.php';
+    }
+
+    /**
+     * Test MarketplaceService.php file exists
+     */
+    public function testServiceFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->servicePath,
+            'MarketplaceService.php must exist'
+        );
+    }
+
+    /**
+     * Test config is valid PHP syntax
+     */
+    public function testServiceIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->servicePath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'MarketplaceService.php must have valid PHP syntax: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test EMAIL_ZAKAZ_PASSWORD uses getenv
+     */
+    public function testEmailZakazPasswordUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('EMAIL_ZAKAZ_PASSWORD')",
+            $content,
+            'MarketplaceService must use EMAIL_ZAKAZ_PASSWORD from env'
+        );
+    }
+
+    /**
+     * Test EMAIL_FLOW_PASSWORD uses getenv
+     */
+    public function testEmailFlowPasswordUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('EMAIL_FLOW_PASSWORD')",
+            $content,
+            'MarketplaceService must use EMAIL_FLOW_PASSWORD from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded email passwords
+     */
+    public function testNoHardcodedEmailPasswords(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        // Check that $password is not assigned a literal string directly
+        // Should be from getenv
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$password\s*=\s*[\'"][a-zA-Z0-9!@#$%^&*()_+-=]{8,}[\'"];/',
+            $content,
+            'MarketplaceService must not have hardcoded email passwords'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback for EMAIL_ZAKAZ_PASSWORD
+     */
+    public function testEmailZakazPasswordHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'EMAIL_ZAKAZ_PASSWORD'\s*\)\s*\?:\s*''/",
+            $content,
+            'EMAIL_ZAKAZ_PASSWORD must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test getenv has empty string fallback for EMAIL_FLOW_PASSWORD
+     */
+    public function testEmailFlowPasswordHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'EMAIL_FLOW_PASSWORD'\s*\)\s*\?:\s*''/",
+            $content,
+            'EMAIL_FLOW_PASSWORD must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test alternate mailbox handling exists
+     */
+    public function testAlternateMailboxHandling(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        // Check that useAlternateMailbox condition exists
+        $this->assertStringContainsString(
+            '$useAlternateMailbox',
+            $content,
+            'MarketplaceService must handle alternate mailbox'
+        );
+    }
+
+    /**
+     * Test email usernames are present
+     */
+    public function testEmailUsernamesPresent(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            'Zakaz-bazacvetov24@yandex.ru',
+            $content,
+            'Zakaz email username must be present'
+        );
+
+        $this->assertStringContainsString(
+            'flow@bazacvetov24.ru',
+            $content,
+            'Flow email username must be present'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+    }
+}
diff --git a/erp24/tests/unit/services/SiteServiceSecretsTest.php b/erp24/tests/unit/services/SiteServiceSecretsTest.php
new file mode 100644 (file)
index 0000000..a334d53
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for SiteService secrets
+ *
+ * Verifies that SiteService uses environment variables
+ * for API URL instead of hardcoded values.
+ *
+ * Covers:
+ * - erp24/services/SiteService.php
+ *
+ * @group services
+ * @group secrets
+ */
+class SiteServiceSecretsTest extends Unit
+{
+    private string $basePath;
+    private string $serviceContent;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+        $this->serviceContent = file_get_contents($this->basePath . '/services/SiteService.php');
+    }
+
+    /**
+     * Test SiteService file exists
+     */
+    public function testFileExists(): void
+    {
+        $filePath = $this->basePath . '/services/SiteService.php';
+        $this->assertFileExists($filePath, 'services/SiteService.php must exist');
+    }
+
+    /**
+     * Test SiteService is valid PHP
+     */
+    public function testIsValidPhp(): void
+    {
+        $filePath = $this->basePath . '/services/SiteService.php';
+        $output = [];
+        $returnCode = 0;
+        exec("php -l " . escapeshellarg($filePath) . " 2>&1", $output, $returnCode);
+        $this->assertEquals(0, $returnCode, 'SiteService.php must be valid PHP: ' . implode("\n", $output));
+    }
+
+    /**
+     * Test SiteService uses SITE_API_URL env
+     */
+    public function testUsesSiteApiUrlEnv(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]SITE_API_URL[\'"]\s*\)/',
+            $this->serviceContent,
+            'SiteService must use getenv(\'SITE_API_URL\')'
+        );
+    }
+
+    /**
+     * Test SiteService has no hardcoded API URL
+     */
+    public function testNoHardcodedApiUrl(): void
+    {
+        // Should not contain hardcoded full URL
+        $this->assertDoesNotMatchRegularExpression(
+            '/https?:\/\/[a-z0-9.-]+\/v1\/order-logs/',
+            $this->serviceContent,
+            'SiteService must not contain hardcoded API URL'
+        );
+    }
+
+    /**
+     * Test SiteService URL is constructed from env
+     */
+    public function testUrlConstructedFromEnv(): void
+    {
+        // URL should be constructed: getenv('SITE_API_URL') . '/v1/order-logs'
+        $this->assertStringContainsString(
+            "getenv('SITE_API_URL')",
+            $this->serviceContent,
+            'SiteService must construct URL from SITE_API_URL env'
+        );
+    }
+
+    /**
+     * Test SiteService has proper namespace
+     */
+    public function testHasProperNamespace(): void
+    {
+        $this->assertStringContainsString(
+            'namespace yii_app\services',
+            $this->serviceContent,
+            'SiteService must have proper namespace'
+        );
+    }
+
+    /**
+     * Test .env.example has SITE_API_URL
+     */
+    public function testEnvExampleHasSiteApiUrl(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'SITE_API_URL=',
+            $envExample,
+            '.env.example must contain SITE_API_URL'
+        );
+    }
+}
diff --git a/erp24/tests/unit/services/TelegramServiceSecretsTest.php b/erp24/tests/unit/services/TelegramServiceSecretsTest.php
new file mode 100644 (file)
index 0000000..6729db9
--- /dev/null
@@ -0,0 +1,253 @@
+<?php
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for TelegramService.php getenv() migration
+ *
+ * Verifies that TelegramService uses environment variables
+ * for all Telegram credentials (5 secrets).
+ *
+ * Covers changes in:
+ * - erp24/services/TelegramService.php
+ *
+ * ENV variables tested:
+ * - TELEGRAM_BOT_TOKEN
+ * - TELEGRAM_BOT_TOKEN_PROD
+ * - TELEGRAM_CHAT_CHANNEL_ID
+ * - TELEGRAM_CHAT_CHANNEL_ERP_ID
+ * - CHATBOT_SALT
+ *
+ * @group config
+ * @group telegram
+ * @group secrets
+ */
+class TelegramServiceSecretsTest extends Unit
+{
+    private string $servicePath;
+
+    protected function _before(): void
+    {
+        $this->servicePath = dirname(__DIR__, 3) . '/services/TelegramService.php';
+    }
+
+    /**
+     * Test TelegramService.php file exists
+     */
+    public function testServiceFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->servicePath,
+            'TelegramService.php must exist'
+        );
+    }
+
+    /**
+     * Test config is valid PHP syntax
+     */
+    public function testServiceIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->servicePath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'TelegramService.php must have valid PHP syntax: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test TELEGRAM_BOT_TOKEN uses getenv
+     */
+    public function testBotTokenUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_BOT_TOKEN')",
+            $content,
+            'TelegramService must use TELEGRAM_BOT_TOKEN from env'
+        );
+    }
+
+    /**
+     * Test TELEGRAM_BOT_TOKEN_PROD uses getenv
+     */
+    public function testBotTokenProdUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_BOT_TOKEN_PROD')",
+            $content,
+            'TelegramService must use TELEGRAM_BOT_TOKEN_PROD from env'
+        );
+    }
+
+    /**
+     * Test TELEGRAM_CHAT_CHANNEL_ID uses getenv
+     */
+    public function testChatChannelIdUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_CHAT_CHANNEL_ID')",
+            $content,
+            'TelegramService must use TELEGRAM_CHAT_CHANNEL_ID from env'
+        );
+    }
+
+    /**
+     * Test TELEGRAM_CHAT_CHANNEL_ERP_ID uses getenv
+     */
+    public function testChatChannelErpIdUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('TELEGRAM_CHAT_CHANNEL_ERP_ID')",
+            $content,
+            'TelegramService must use TELEGRAM_CHAT_CHANNEL_ERP_ID from env'
+        );
+    }
+
+    /**
+     * Test CHATBOT_SALT uses getenv
+     */
+    public function testChatbotSaltUsesEnv(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            "getenv('CHATBOT_SALT')",
+            $content,
+            'TelegramService must use CHATBOT_SALT from env'
+        );
+    }
+
+    /**
+     * Test no hardcoded bot tokens
+     */
+    public function testNoHardcodedBotToken(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        // Known hardcoded token patterns (masked format)
+        $this->assertDoesNotMatchRegularExpression(
+            '/\d{10}:AA[A-Za-z0-9_-]{33}/',
+            $content,
+            'Must not contain hardcoded Telegram bot token'
+        );
+    }
+
+    /**
+     * Test no hardcoded chat IDs as constants
+     */
+    public function testNoHardcodedChatIdConstants(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        // Check no const with numeric chat ID
+        $this->assertDoesNotMatchRegularExpression(
+            '/const\s+.*CHAT.*=\s*[\'\"]-?\d{9,12}[\'\"]\s*;/',
+            $content,
+            'Must not have const with hardcoded chat ID'
+        );
+    }
+
+    /**
+     * Test private getter methods exist
+     */
+    public function testPrivateGetterMethodsExist(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            'private static function getBotTokenDev()',
+            $content,
+            'getBotTokenDev() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            'private static function getBotTokenProd()',
+            $content,
+            'getBotTokenProd() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            'private static function getChatChannelId()',
+            $content,
+            'getChatChannelId() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            'private static function getChatChannelErpId()',
+            $content,
+            'getChatChannelErpId() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            'private static function getChatbotSalt()',
+            $content,
+            'getChatbotSalt() method must exist'
+        );
+    }
+
+    /**
+     * Test all getenv calls have empty string fallback
+     */
+    public function testGetenvHasEmptyFallback(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        // Pattern: getenv('VAR') ?: ''
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'TELEGRAM_BOT_TOKEN'\s*\)\s*\?:\s*''/",
+            $content,
+            'TELEGRAM_BOT_TOKEN must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'TELEGRAM_BOT_TOKEN_PROD'\s*\)\s*\?:\s*''/",
+            $content,
+            'TELEGRAM_BOT_TOKEN_PROD must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'TELEGRAM_CHAT_CHANNEL_ID'\s*\)\s*\?:\s*''/",
+            $content,
+            'TELEGRAM_CHAT_CHANNEL_ID must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'TELEGRAM_CHAT_CHANNEL_ERP_ID'\s*\)\s*\?:\s*''/",
+            $content,
+            'TELEGRAM_CHAT_CHANNEL_ERP_ID must have empty string fallback'
+        );
+
+        $this->assertMatchesRegularExpression(
+            "/getenv\s*\(\s*'CHATBOT_SALT'\s*\)\s*\?:\s*''/",
+            $content,
+            'CHATBOT_SALT must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test PHPDoc documentation exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $content = file_get_contents($this->servicePath);
+
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $content,
+            'PHPDoc with ENV documentation must exist'
+        );
+    }
+}
diff --git a/erp24/tests/unit/services/TelegramTargetSecretsTest.php b/erp24/tests/unit/services/TelegramTargetSecretsTest.php
new file mode 100644 (file)
index 0000000..5ea4fab
--- /dev/null
@@ -0,0 +1,219 @@
+<?php
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for TelegramTarget secrets migration
+ *
+ * Verifies that TelegramTarget.php uses environment variables
+ * instead of hardcoded credentials.
+ *
+ * Covers:
+ * - erp24/services/TelegramTarget.php
+ *
+ * @group services
+ * @group telegram
+ * @group secrets
+ */
+class TelegramTargetSecretsTest extends Unit
+{
+    private string $filePath;
+    private string $fileContent;
+
+    protected function _before(): void
+    {
+        $this->filePath = dirname(__DIR__, 3) . '/services/TelegramTarget.php';
+        $this->fileContent = file_get_contents($this->filePath);
+    }
+
+    /**
+     * Test file exists
+     */
+    public function testFileExists(): void
+    {
+        $this->assertFileExists(
+            $this->filePath,
+            'TelegramTarget.php must exist'
+        );
+    }
+
+    /**
+     * Test file is valid PHP
+     */
+    public function testIsValidPhp(): void
+    {
+        $output = [];
+        $returnCode = 0;
+        exec("php -l {$this->filePath} 2>&1", $output, $returnCode);
+
+        $this->assertEquals(
+            0,
+            $returnCode,
+            'TelegramTarget.php must be valid PHP: ' . implode("\n", $output)
+        );
+    }
+
+    /**
+     * Test bot token uses getenv()
+     */
+    public function testBotTokenUsesEnv(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_TOKEN[\'"]\s*\)/',
+            $this->fileContent,
+            'botToken must use getenv(\'TELEGRAM_BOT_TOKEN\')'
+        );
+    }
+
+    /**
+     * Test chat ID uses getenv()
+     */
+    public function testChatIdUsesEnv(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_CHAT_CHANNEL_ERP_ID[\'"]\s*\)/',
+            $this->fileContent,
+            'chatId must use getenv(\'TELEGRAM_CHAT_CHANNEL_ERP_ID\')'
+        );
+    }
+
+    /**
+     * Test no hardcoded Telegram bot token (format: NNNNNNNNNN:AA...)
+     */
+    public function testNoHardcodedBotToken(): void
+    {
+        // Real Telegram token format: 10 digits : AA + 33 chars
+        $this->assertDoesNotMatchRegularExpression(
+            '/["\'][0-9]{9,11}:AA[A-Za-z0-9_-]{30,}["\']/',
+            $this->fileContent,
+            'TelegramTarget.php must not contain hardcoded bot token'
+        );
+    }
+
+    /**
+     * Test no hardcoded chat ID (format: -100XXXXXXXXX)
+     */
+    public function testNoHardcodedChatId(): void
+    {
+        // Telegram channel IDs start with -100
+        $this->assertDoesNotMatchRegularExpression(
+            '/["\']?-100\d{10,}["\']?\s*;/',
+            $this->fileContent,
+            'TelegramTarget.php must not contain hardcoded chat ID'
+        );
+    }
+
+    /**
+     * Test getenv has empty fallback for bot token
+     */
+    public function testBotTokenHasEmptyFallback(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_BOT_TOKEN[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $this->fileContent,
+            'botToken getenv must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test getenv has empty fallback for chat ID
+     */
+    public function testChatIdHasEmptyFallback(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/getenv\s*\(\s*[\'"]TELEGRAM_CHAT_CHANNEL_ERP_ID[\'"]\s*\)\s*\?:\s*[\'"][\'"]/',
+            $this->fileContent,
+            'chatId getenv must have empty string fallback'
+        );
+    }
+
+    /**
+     * Test PHP doc exists
+     */
+    public function testPhpDocExists(): void
+    {
+        $this->assertStringContainsString(
+            'ВАЖНО для ERP24',
+            $this->fileContent,
+            'TelegramTarget.php must have documentation comment'
+        );
+    }
+
+    /**
+     * Test init() method exists and sets credentials
+     */
+    public function testInitMethodSetsCredentials(): void
+    {
+        $this->assertStringContainsString(
+            'public function init()',
+            $this->fileContent,
+            'init() method must exist'
+        );
+
+        $this->assertStringContainsString(
+            '$this->botToken',
+            $this->fileContent,
+            'init() must set botToken'
+        );
+
+        $this->assertStringContainsString(
+            '$this->chatId',
+            $this->fileContent,
+            'init() must set chatId'
+        );
+    }
+
+    /**
+     * Test properties are declared without default values
+     */
+    public function testPropertiesDeclaredWithoutDefaults(): void
+    {
+        // botToken should be declared without hardcoded value
+        $this->assertMatchesRegularExpression(
+            '/public\s+\$botToken\s*;/',
+            $this->fileContent,
+            'botToken property must be declared without default value'
+        );
+
+        // chatId should be declared without hardcoded value
+        $this->assertMatchesRegularExpression(
+            '/public\s+\$chatId\s*;/',
+            $this->fileContent,
+            'chatId property must be declared without default value'
+        );
+    }
+
+    /**
+     * Test allows override via config
+     */
+    public function testAllowsOverrideViaConfig(): void
+    {
+        // Pattern: $this->property = $this->property ?: getenv(...)
+        // This allows setting values via Yii config
+        $this->assertMatchesRegularExpression(
+            '/\$this->botToken\s*=\s*\$this->botToken\s*\?:/',
+            $this->fileContent,
+            'botToken must allow override via config (ternary pattern)'
+        );
+
+        $this->assertMatchesRegularExpression(
+            '/\$this->chatId\s*=\s*\$this->chatId\s*\?:/',
+            $this->fileContent,
+            'chatId must allow override via config (ternary pattern)'
+        );
+    }
+
+    /**
+     * Test references .env.example
+     */
+    public function testReferencesEnvExample(): void
+    {
+        $this->assertStringContainsString(
+            '@see erp24/.env.example',
+            $this->fileContent,
+            'TelegramTarget.php must reference .env.example'
+        );
+    }
+}
diff --git a/erp24/tests/unit/services/WhatsAppServiceSecretsTest.php b/erp24/tests/unit/services/WhatsAppServiceSecretsTest.php
new file mode 100644 (file)
index 0000000..f619073
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+
+/**
+ * Tests for WhatsAppService secrets
+ *
+ * Verifies that WhatsAppService uses environment variables
+ * for API key and doesn't contain hardcoded credentials.
+ *
+ * Covers:
+ * - erp24/services/WhatsAppService.php
+ *
+ * @group services
+ * @group whatsapp
+ * @group secrets
+ */
+class WhatsAppServiceSecretsTest extends Unit
+{
+    private string $basePath;
+    private string $serviceContent;
+
+    protected function _before(): void
+    {
+        $this->basePath = dirname(__DIR__, 3);
+        $this->serviceContent = file_get_contents($this->basePath . '/services/WhatsAppService.php');
+    }
+
+    /**
+     * Test WhatsAppService file exists
+     */
+    public function testFileExists(): void
+    {
+        $filePath = $this->basePath . '/services/WhatsAppService.php';
+        $this->assertFileExists($filePath, 'services/WhatsAppService.php must exist');
+    }
+
+    /**
+     * Test WhatsAppService is valid PHP
+     */
+    public function testIsValidPhp(): void
+    {
+        $filePath = $this->basePath . '/services/WhatsAppService.php';
+        $output = [];
+        $returnCode = 0;
+        exec("php -l " . escapeshellarg($filePath) . " 2>&1", $output, $returnCode);
+        $this->assertEquals(0, $returnCode, 'WhatsAppService.php must be valid PHP: ' . implode("\n", $output));
+    }
+
+    /**
+     * Test WhatsAppService has proper namespace
+     */
+    public function testHasProperNamespace(): void
+    {
+        $this->assertStringContainsString(
+            'namespace yii_app\services',
+            $this->serviceContent,
+            'WhatsAppService must have proper namespace'
+        );
+    }
+
+    /**
+     * Test WhatsAppService accepts apiKey as constructor parameter
+     */
+    public function testAcceptsApiKeyAsParameter(): void
+    {
+        // API key should be passed via constructor, not hardcoded
+        $this->assertMatchesRegularExpression(
+            '/function\s+__construct\s*\(\s*\$apiKey/',
+            $this->serviceContent,
+            'WhatsAppService must accept apiKey as constructor parameter'
+        );
+    }
+
+    /**
+     * Test WhatsAppService stores apiKey as private property
+     */
+    public function testStoresApiKeyAsPrivate(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/private\s+\$apiKey/',
+            $this->serviceContent,
+            'WhatsAppService must store apiKey as private property'
+        );
+    }
+
+    /**
+     * Test WhatsAppService has no hardcoded API key
+     */
+    public function testNoHardcodedApiKey(): void
+    {
+        // Should not contain hardcoded API key (long alphanumeric string)
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$apiKey\s*=\s*[\'"][a-zA-Z0-9]{30,}[\'"]/',
+            $this->serviceContent,
+            'WhatsAppService must not contain hardcoded API key'
+        );
+    }
+
+    /**
+     * Test WhatsAppService has no hardcoded cascade ID
+     */
+    public function testNoHardcodedCascadeId(): void
+    {
+        // Should not contain hardcoded cascade ID
+        $this->assertDoesNotMatchRegularExpression(
+            '/\$cascadeId\s*=\s*[\'"][a-zA-Z0-9-]{20,}[\'"]/',
+            $this->serviceContent,
+            'WhatsAppService must not contain hardcoded cascadeId'
+        );
+    }
+
+    /**
+     * Test WhatsAppService uses static API base URL (acceptable for endpoint)
+     */
+    public function testHasApiBaseUrl(): void
+    {
+        // It's acceptable to have a static API base URL for the service endpoint
+        $this->assertStringContainsString(
+            '$apiBaseUrl',
+            $this->serviceContent,
+            'WhatsAppService should have apiBaseUrl property'
+        );
+    }
+
+    /**
+     * Test WhatsAppService constructor assigns apiKey
+     */
+    public function testConstructorAssignsApiKey(): void
+    {
+        $this->assertMatchesRegularExpression(
+            '/\$this->apiKey\s*=\s*\$apiKey/',
+            $this->serviceContent,
+            'WhatsAppService constructor must assign apiKey'
+        );
+    }
+
+    /**
+     * Test .env.example has WHATSAPP_API_KEY
+     */
+    public function testEnvExampleHasWhatsAppApiKey(): void
+    {
+        $envExample = file_get_contents($this->basePath . '/.env.example');
+
+        $this->assertStringContainsString(
+            'WHATSAPP_API_KEY=',
+            $envExample,
+            '.env.example must contain WHATSAPP_API_KEY'
+        );
+    }
+}