claude-flow
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
+/.claude/
--- /dev/null
+<?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,
+ ],
+];
+
+
+
+
+
+
+
+
+
+
--- /dev/null
+<?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';
+}
+
+
+
+
+
+
+
+
+
+
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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) . "..."
+ );
+ }
+ }
+}
--- /dev/null
+<?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}"
+ );
+ }
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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}"
+ );
+ }
+ }
+}
--- /dev/null
+<?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');
+ }
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+ }
+}
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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"
+ );
+ }
+ }
+}
--- /dev/null
+<?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');
+ }
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+ }
+}
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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));
+ }
+}
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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';
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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"
+ );
+ }
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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);
+ }
+}
+
+
+
+
+
+
+
+
+
+
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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'
+ );
+ }
+}