diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php index eac78802..34ca2d43 100644 --- a/app/Console/Commands/ApplicationReady.php +++ b/app/Console/Commands/ApplicationReady.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Utilities\SystemDatabaseConfiguration; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Throwable; @@ -18,7 +19,7 @@ class ApplicationReady extends Command protected $description = '检查应用安装、运行目录和数据库是否已经可用'; - public function handle(): int + public function handle(SystemDatabaseConfiguration $configuration): int { if (! config('app.installed')) { return $this->failed('APP_INSTALLED 未被 Laravel 加载。', self::EXIT_NOT_INSTALLED); @@ -42,7 +43,11 @@ class ApplicationReady extends Command return $this->failed('Laravel 数据库连接失败。', self::EXIT_DATABASE_CONNECTION_FAILED); } - $this->components->info('应用已就绪。'); + if ($configuration->current()['mode'] === 'bootstrap') { + $this->components->info('本地网页和临时引导库已就绪;团队共享数据尚未接入。'); + } else { + $this->components->info('应用运行正常,当前数据库可查询。团队数据接入状态请运行 system-database:verify 检查。'); + } return self::SUCCESS; } diff --git a/app/Console/Commands/VerifySystemDatabase.php b/app/Console/Commands/VerifySystemDatabase.php index 658965b0..52d8ddb0 100644 --- a/app/Console/Commands/VerifySystemDatabase.php +++ b/app/Console/Commands/VerifySystemDatabase.php @@ -15,6 +15,8 @@ class VerifySystemDatabase extends Command public const EXIT_SYSTEM_TABLE_VALIDATION_FAILED = 12; + public const EXIT_SHARED_NOT_CONFIGURED = 13; + protected $signature = 'system-database:verify'; protected $description = '在新的 Laravel 进程中验证当前共享系统数据库'; @@ -30,12 +32,16 @@ class VerifySystemDatabase extends Command ); } - if ( - $settings['mode'] !== 'shared' - || ! config('upstreams.stored_configuration.enabled', false) - ) { + if ($settings['mode'] === 'bootstrap') { + $this->components->warn('当前仍使用本机临时引导库,尚未接入团队共享系统数据库;本次没有测试远程连接。'); + $this->line('下一步:在“系统设置 → 数据库管理 → 系统数据库”中输入一次负责人提供的系统数据库密码。'); + + return self::EXIT_SHARED_NOT_CONFIGURED; + } + + if ($settings['mode'] !== 'shared' || ! config('upstreams.stored_configuration.enabled', false)) { return $this->failed( - '新配置未被 Laravel 加载。', + '共享配置未完整启用,请通过系统数据库连接页面重新保存;不要仅手工修改运行模式。', self::EXIT_CONFIG_NOT_LOADED ); } diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 0eb9b009..581ba4b8 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -15,6 +15,7 @@ use Illuminate\Routing\Controller; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\UpstreamOdbcConnection; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -258,24 +259,26 @@ class Databases extends Controller return redirect()->route('system-settings.databases.index'); } - public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration) + public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration, UpstreamDatabaseProbe $probe) { abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403); $connection = $this->connection($connection); try { - UpstreamOdbcConnection::probeConfiguration($this->odbcConfiguration($connection), $connection->name); + $probe->probe($connection->driver, $this->odbcConfiguration($connection), $connection->name); } catch (DecryptException $exception) { throw ValidationException::withMessages([ 'connection' => $exception->getMessage(), ]); - } catch (ServiceUnavailableHttpException) { + } catch (ServiceUnavailableHttpException $exception) { throw ValidationException::withMessages([ - 'connection' => '连接失败。请检查地址、端口、数据库、账号、网络白名单和本机 ODBC 驱动。', + 'connection' => $connection->driver === 'pgsql' + ? $exception->getMessage() + : 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。', ]); } - flash($connection->name . '连接成功,仅执行了 SELECT 1 只读测试。')->success(); + flash($connection->name . ($connection->driver === 'pgsql' ? ' PostgreSQL SSL 连接成功,仅执行了 SELECT 1 只读测试。' : '连接成功,仅执行了 SELECT 1 只读测试。'))->success(); return redirect()->route('system-settings.databases.index'); } @@ -283,8 +286,17 @@ class Databases extends Controller private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array { $sourceKeys = array_keys($this->sourceLabels()); + $request->merge(['driver' => $request->input('driver', $connection?->driver ?? 'sqlsrv')]); + $postgres = $request->input('driver') === 'pgsql'; + + if ($postgres && $request->input('source_key', $connection?->source_key)) { + throw ValidationException::withMessages([ + 'source_key' => '现有业务来源使用 SQL Server,不能改绑到 PostgreSQL。请为 PostgreSQL 单独保存连接。', + ]); + } return $request->validate([ + 'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])], 'name' => [ 'required', 'string', @@ -300,15 +312,20 @@ class Databases extends Controller ->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)) ->ignore($connection?->id), ], - 'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'host' => ['required', 'string', 'max:191', function ($attribute, $value, $fail) { + if (! filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + && ! preg_match('/^[a-z0-9_.-]+$/i', $value)) { + $fail('主机地址只填写域名或 IP,端口请单独填写。'); + } + }], 'port' => ['required', 'integer', 'between:1,65535'], 'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'], - 'odbc_driver' => ['required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], - 'tds_version' => ['required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], + 'odbc_driver' => [$postgres ? 'nullable' : 'required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], + 'tds_version' => [$postgres ? 'nullable' : 'required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], 'connect_timeout' => ['required', 'integer', 'between:1,30'], - 'query_timeout' => ['required', 'integer', 'between:1,120'], + 'query_timeout' => ['required', 'integer', $postgres ? 'between:1,15' : 'between:1,120'], 'allowed_scopes' => ['nullable', 'string', 'max:4000'], 'enabled' => ['nullable', 'boolean'], ]); @@ -318,15 +335,15 @@ class Databases extends Controller { $attributes = [ 'company_id' => UpstreamStoredConfiguration::SYSTEM_COMPANY_ID, - 'source_key' => $data['source_key'] ?? null, + 'source_key' => array_key_exists('source_key', $data) ? $data['source_key'] : $connection?->source_key, 'name' => trim($data['name']), - 'driver' => 'sqlsrv', + 'driver' => $data['driver'], 'host' => trim($data['host']), 'port' => (int) $data['port'], 'database_name' => trim($data['database_name']), 'username' => trim($data['username']), - 'odbc_driver' => trim($data['odbc_driver']), - 'tds_version' => trim($data['tds_version']), + 'odbc_driver' => $data['driver'] === 'pgsql' ? null : trim($data['odbc_driver']), + 'tds_version' => $data['driver'] === 'pgsql' ? null : trim($data['tds_version']), 'connect_timeout' => (int) $data['connect_timeout'], 'query_timeout' => (int) $data['query_timeout'], 'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')), @@ -374,6 +391,7 @@ class Databases extends Controller { return [ 'name' => '', + 'driver' => 'sqlsrv', 'source_key' => '', 'enabled' => false, 'host' => '', @@ -392,6 +410,7 @@ class Databases extends Controller { return [ 'name' => $connection->name, + 'driver' => $connection->driver, 'source_key' => $connection->source_key, 'enabled' => $connection->enabled, 'host' => $connection->host, diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index 312100f4..e95eb153 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -462,6 +462,7 @@ class SystemDatabaseConfiguration VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED => '新配置未被加载', VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED => '系统表验证失败', + VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED => '新配置仍指向本机临时引导库', default => '应用级验证失败', }; } diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2024a960..aba499ae 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -72,6 +72,12 @@ RUN --mount=type=cache,target=/tmp/composer-cache \ FROM php-base AS runtime +# Keep the existing PHP/Composer cache layers when adding PostgreSQL support. +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + ARG VCS_REF=unknown ARG BUILD_DATE=unknown diff --git a/deploy/Dockerfile.local b/deploy/Dockerfile.local index 2f1383b5..eb2307bc 100644 --- a/deploy/Dockerfile.local +++ b/deploy/Dockerfile.local @@ -83,6 +83,11 @@ RUN --mount=type=cache,target=/root/.npm \ npm ci --no-audit --no-fund --legacy-peer-deps # ---------------- 复制源码 ---------------- +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + COPY . /var/www/html COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini diff --git a/deploy/local-init.sh b/deploy/local-init.sh index a01ff624..e2a32135 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -397,16 +397,30 @@ fi echo "" echo "=====================================================" -echo "本地测试环境已就绪:http://127.0.0.1:18088" +echo "本地网页可访问:http://127.0.0.1:18088" -db_host="$(read_env_value DB_HOST)" -if [ "$db_host" = "mysql" ]; then - echo "临时登录凭据保存在本机私有文件:$credential_file" - echo "请自行打开该文件,不要把内容提交到 Git、聊天或日志。" - echo "" - echo "下一步:登录后打开“系统设置 → 数据库管理 → 系统数据库”," - echo "点击“连接”,只填写负责人提供的系统数据库密码。" +# Docker healthy 只表示当前连接可用;共享接入必须由新 Laravel 进程实测, +# 不能根据 DB_HOST 不是 mysql 就宣称团队数据已连接。 +if docker compose -f "$compose_file" exec -T -u www-data app \ + php artisan system-database:verify --no-ansi; then + shared_status=0 else - echo "已连接团队共享系统数据库,请使用其中属于自己的正式账号登录。" + shared_status=$? fi + +case "$shared_status" in + 0) + echo "团队共享系统数据库验证通过,请使用其中属于自己的正式账号登录。" + ;; + 13) + echo "团队数据接入尚未完成;后台 worker 启动不代表数据已经同步。" + echo "临时登录凭据保存在本机私有文件:$credential_file" + echo "请登录后输入一次系统数据库密码,连接成功后改用团队正式账号登录。" + ;; + *) + echo "团队共享系统数据库未通过验证;不要将 Docker healthy 当作接入成功。" >&2 + echo "请让 Codex 根据上方失败阶段检查本机配置,保留现有配置和未提交代码。" >&2 + exit "$shared_status" + ;; +esac echo "=====================================================" diff --git a/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php new file mode 100644 index 00000000..a41b9ff1 --- /dev/null +++ b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php @@ -0,0 +1,68 @@ +quote((string) $connection['host']) + . ';port=' . min(max((int) $connection['port'], 1), 65535) + . ';dbname=' . $this->quote((string) $connection['database']) + . ';sslmode=require;connect_timeout=' . $timeout + . ";options='-c default_transaction_read_only=on -c statement_timeout=" . $queryTimeout . "'"; + + try { + // One short-lived connection, TLS and read-only enforced at session startup. + $pdo = $this->connect($dsn, (string) $connection['username'], (string) $connection['password']); + $statement = $pdo->query('SELECT 1'); + if ($statement === false || (int) $statement->fetchColumn() !== 1) { + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 只读测试未返回预期结果。'); + } + $statement->closeCursor(); + } catch (PDOException) { + // Do not expose raw driver errors, DSNs or credentials in logs/session flashes. + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 连接失败。请检查账号、密码、数据库访问权限,以及服务器网络和 SSL 配置。'); + } + } + + protected function connect(string $dsn, string $username, #[SensitiveParameter] string $password): PDO + { + if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { + throw new ServiceUnavailableHttpException(30, '应用缺少 PostgreSQL 驱动,请部署包含 pdo_pgsql 的新版应用镜像。'); + } + + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_PERSISTENT => false, + ]); + } + + private function quote(string $value): string + { + // PDO replaces all DSN semicolons with spaces; reject rather than alter database names. + if (preg_match('/[;\x00-\x1F]/', $value)) { + throw new ServiceUnavailableHttpException(30, '主机或数据库名称包含不支持的字符。'); + } + + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; + } +} diff --git a/resources/views/system_settings/databases/form.blade.php b/resources/views/system_settings/databases/form.blade.php index 9cd2946e..7e3e5cdf 100644 --- a/resources/views/system_settings/databases/form.blade.php +++ b/resources/views/system_settings/databases/form.blade.php @@ -5,7 +5,8 @@
@include('system_settings.partials.navigation') -
+ @php($postgres = old('driver', $values['driver']) === 'pgsql') + @csrf @if ($connection) @method('PATCH') @endif @@ -16,64 +17,97 @@ @endif
+ +

通过 SSL 加密连接(sslmode=require);仅执行只读测试,查询最长 15 秒。保存连接不会自动同步业务表。

-
高级设置
- - - - - + + + + +
- +
取消 - +
+ @push('body_end') + + @endpush diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index 3df03c6b..d38520ea 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -96,7 +96,7 @@ @forelse ($connections as $connection) - {{ $connection->name }} + {{ $connection->name }}{{ $connection->driver === 'pgsql' ? 'PostgreSQL · SSL' : 'SQL Server' }} {{ $connection->host }}:{{ $connection->port }} {{ $connection->database_name }} {{ $sourceLabels[$connection->source_key] ?? '未绑定' }} diff --git a/tests/Feature/Installation/ApplicationReadyTest.php b/tests/Feature/Installation/ApplicationReadyTest.php index 74f5cc4d..947cb0f0 100644 --- a/tests/Feature/Installation/ApplicationReadyTest.php +++ b/tests/Feature/Installation/ApplicationReadyTest.php @@ -18,4 +18,20 @@ class ApplicationReadyTest extends TestCase $this->artisan('app:ready') ->assertExitCode(\App\Console\Commands\ApplicationReady::EXIT_NOT_INSTALLED); } + + public function test_bootstrap_stays_healthy_without_claiming_shared_data_is_ready(): void + { + config(['app.installed' => true, 'system_database.mode' => 'bootstrap']); + $this->artisan('app:ready') + ->expectsOutputToContain('团队共享数据尚未接入') + ->assertSuccessful(); + } + + public function test_shared_application_health_does_not_claim_upstream_sync_success(): void + { + config(['app.installed' => true, 'system_database.mode' => 'shared']); + $this->artisan('app:ready') + ->expectsOutputToContain('团队数据接入状态请运行 system-database:verify') + ->assertSuccessful(); + } } diff --git a/tests/Feature/Installation/VerifySystemDatabaseTest.php b/tests/Feature/Installation/VerifySystemDatabaseTest.php index 95284d83..059cba2a 100644 --- a/tests/Feature/Installation/VerifySystemDatabaseTest.php +++ b/tests/Feature/Installation/VerifySystemDatabaseTest.php @@ -10,7 +10,7 @@ use Tests\TestCase; class VerifySystemDatabaseTest extends TestCase { - public function test_verify_command_reports_configuration_not_loaded(): void + public function test_bootstrap_is_not_reported_as_a_failed_remote_connection(): void { $configuration = Mockery::mock(SystemDatabaseConfiguration::class); $configuration->shouldReceive('current')->once()->andReturn([ @@ -19,9 +19,42 @@ class VerifySystemDatabaseTest extends TestCase $this->app->instance(SystemDatabaseConfiguration::class, $configuration); $this->artisan('system-database:verify') + ->expectsOutputToContain('尚未接入团队共享系统数据库;本次没有测试远程连接') + ->assertExitCode(VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED); + } + + public function test_shared_mode_with_disabled_stored_configuration_is_not_ready(): void + { + config(['upstreams.stored_configuration.enabled' => false]); + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn(['mode' => 'shared']); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify') + ->expectsOutputToContain('共享配置未完整启用') ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED); } + public function test_current_configuration_failure_remains_a_configuration_error(): void + { + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andThrow(new RuntimeException('private details')); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify') + ->expectsOutputToContain('新配置未被 Laravel 加载') + ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED); + } + + public function test_shared_success_requires_connection_and_system_table_verification(): void + { + $this->enableSharedMode(); + $settings = ['mode' => 'shared', 'connection' => config('database.default')]; + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn($settings); + $configuration->shouldReceive('probe')->once()->with($settings)->andReturn([]); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify')->expectsOutputToContain('共享系统数据库已通过应用级验证')->assertSuccessful(); + } + public function test_verify_command_reports_database_connection_failure(): void { $this->enableSharedMode(); diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 61e29e86..1812ff0f 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Mockery; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Tests\Feature\FeatureTestCase; class UpstreamDatabasesTest extends FeatureTestCase @@ -499,6 +500,65 @@ class UpstreamDatabasesTest extends FeatureTestCase ->assertSessionHasErrors('password'); } + public function test_postgres_can_be_saved_without_sql_server_settings(): void + { + $payload = $this->payload(['driver' => 'pgsql', 'port' => 25432, 'password' => 'pg-secret']); + unset($payload['odbc_driver'], $payload['tds_version']); + $this->loginAs()->post(route('system-settings.databases.store'), $payload) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $connection = DatabaseConnection::where('driver', 'pgsql')->firstOrFail(); + $this->assertNull($connection->odbc_driver); + $this->assertNull($connection->source_key); + $this->assertSame('pg-secret', $connection->password); + $this->get(route('system-settings.databases.edit', $connection->id)) + ->assertOk()->assertSee('sslmode=require')->assertSee('PostgreSQL')->assertDontSee('pg-secret'); + } + + public function test_changing_wrong_driver_preserves_password(): void + { + $connection = $this->connection('keep-secret'); + $this->loginAs()->patch(route('system-settings.databases.update', $connection->id), $this->payload([ + 'driver' => 'pgsql', 'port' => 25432, 'password' => '', + ]))->assertSessionHasNoErrors(); + $connection->refresh(); + $this->assertSame('pgsql', $connection->driver); + $this->assertSame(25432, $connection->port); + $this->assertSame('keep-secret', $connection->password); + } + + public function test_postgres_cannot_replace_existing_sql_server_business_source(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'driver' => 'pgsql', 'source_key' => 'operations_replica', + ]))->assertSessionHasErrors('source_key'); + $this->assertSame(0, DatabaseConnection::where('company_id', 0)->count()); + } + + public function test_host_must_not_include_port(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'host' => '127.0.0.1:25432', + ]))->assertSessionHasErrors('host'); + } + + public function test_test_button_uses_saved_postgres_driver_and_password(): void + { + $connection = $this->connection('saved-pg-secret'); + $connection->update(['driver' => 'pgsql', 'port' => 25432]); + $probe = Mockery::mock(UpstreamDatabaseProbe::class); + $probe->shouldReceive('probe')->once()->withArgs(fn ($driver, $config, $label) => + $driver === 'pgsql' && $config['password'] === 'saved-pg-secret' && $config['port'] === 25432 + ); + $this->app->instance(UpstreamDatabaseProbe::class, $probe); + $this->loginAs()->post(route('system-settings.databases.test', $connection->id)) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $this->assertFlashLevel('success'); + $this->assertStringContainsString('PostgreSQL SSL 连接成功', session('flash_notification')->first()->message); + $this->get(route('system-settings.databases.index'))->assertSeeText('PostgreSQL')->assertDontSee('saved-pg-secret'); + } + private function payload(array $overrides = []): array { return array_merge([ diff --git a/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php new file mode 100644 index 00000000..13c6b4a5 --- /dev/null +++ b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php @@ -0,0 +1,39 @@ +assertNotFalse($start); + $process = new Process(['bash', '-c', + 'set -eu; compose_file=fixture; credential_file=fixture; docker() { return ' . $verificationExit . '; }; ' + . substr($script, $start), + ]); + $process->setTimeout(5); + $process->run(); + $this->assertSame($expectedExit, $process->getExitCode()); + $this->assertStringContainsString($message, $process->getOutput() . $process->getErrorOutput()); + if ($verificationExit !== 0) { + $this->assertStringNotContainsString('团队共享系统数据库验证通过', $process->getOutput()); + } + } + + public static function verificationStates(): array + { + return [ + 'bootstrap' => [13, 0, '团队数据接入尚未完成'], + 'shared success' => [0, 0, '团队共享系统数据库验证通过'], + 'config failure' => [10, 10, '未通过验证'], + 'network failure' => [11, 11, '未通过验证'], + 'schema failure' => [12, 12, '未通过验证'], + ]; + } +} diff --git a/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php new file mode 100644 index 00000000..0f66222e --- /dev/null +++ b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php @@ -0,0 +1,65 @@ + 'db.example.test', 'port' => 25432, 'database' => "test' database", + 'username' => 'reader', 'password' => 'fixture-secret', 'connect_timeout' => 5, 'query_timeout' => 120]; + } + + public function test_postgres_uses_tls_read_only_timeout_and_one_select(): void + { + $statement = Mockery::mock(PDOStatement::class); + $statement->shouldReceive('fetchColumn')->once()->andReturn(1); + $statement->shouldReceive('closeCursor')->once(); + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('query')->once()->with('SELECT 1')->andReturn($statement); + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->withArgs(function ($dsn, $user, $password) { + $this->assertStringContainsString('sslmode=require', $dsn); + $this->assertStringContainsString('default_transaction_read_only=on', $dsn); + $this->assertStringContainsString('statement_timeout=15000', $dsn); + $this->assertStringContainsString("dbname='test\\' database'", $dsn); + $this->assertStringContainsString('connect_timeout=5', $dsn); + $this->assertStringNotContainsString('fixture-secret', $dsn); + + return $user === 'reader' && $password === 'fixture-secret'; + })->andReturn($pdo); + $probe->probe('pgsql', $this->connection(), '测试库'); + } + + public function test_driver_error_never_exposes_credentials(): void + { + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->andThrow(new PDOException('fixture-secret private driver detail')); + try { + $probe->probe('pgsql', $this->connection(), '测试库'); + $this->fail('Expected sanitized connection error'); + } catch (ServiceUnavailableHttpException $exception) { + $this->assertStringContainsString('PostgreSQL 连接失败', $exception->getMessage()); + $this->assertStringNotContainsString('fixture-secret', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + } + } + + public function test_unsupported_driver_is_not_probed(): void + { + $this->expectException(ServiceUnavailableHttpException::class); + $this->expectExceptionMessage('不支持此数据库类型'); + (new UpstreamDatabaseProbe())->probe('unknown', [], '测试库'); + } +}