user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $connection = (string) config('database.default'); config([ "database.connections.$connection.password" => 'shared-system-database-secret', 'system_database.mode' => 'shared', 'upstreams.stored_configuration.enabled' => true, ]); } public function test_super_admin_can_add_multiple_encrypted_database_configurations(): void { $this->loginAs() ->post(route('system-settings.databases.store'), $this->payload([ 'name' => 'JianSun 测试库', 'source_key' => 'jiansun', 'password' => 'first-secret', ])) ->assertRedirect(route('system-settings.databases.index')); $this->post(route('system-settings.databases.store'), $this->payload([ 'name' => '云数据库二号', 'source_key' => '', 'host' => '127.0.0.2', 'password' => 'second-secret', ]))->assertRedirect(route('system-settings.databases.index')); $this->assertSame(2, DatabaseConnection::where('company_id', 0)->count()); $connection = DatabaseConnection::where('source_key', 'jiansun')->firstOrFail(); $this->assertSame('first-secret', $connection->password); $this->assertNotSame( 'first-secret', DB::table('system_database_connections')->where('id', $connection->id)->value('password') ); $sharedPassword = DB::table('system_database_connections') ->where('id', $connection->id) ->value('shared_password'); $this->assertIsString($sharedPassword); $this->assertNotSame('first-secret', $sharedPassword); $this->assertSame( 'first-secret', app(UpstreamCredentialCipher::class)->decrypt($sharedPassword) ); $sources = app(UpstreamSourceRegistry::class); $this->assertSame('127.0.0.1', $sources->value('jiansun', 'connection.host')); $this->assertSame('first-secret', $sources->value('jiansun', 'connection.password')); $this->get(route('system-settings.databases.index')) ->assertOk() ->assertSeeText('JianSun 测试库') ->assertSeeText('云数据库二号') ->assertDontSee('first-secret'); } public function test_editing_without_password_preserves_existing_password(): void { $connection = $this->connection('original-secret'); $this->loginAs() ->patch(route('system-settings.databases.update', $connection->id), $this->payload([ 'name' => $connection->name, 'password' => '', 'host' => '127.0.0.2', ])) ->assertRedirect(route('system-settings.databases.index')); $connection->refresh(); $this->assertSame('original-secret', $connection->password); $this->assertSame('127.0.0.2', $connection->host); $this->get(route('system-settings.databases.edit', $connection->id)) ->assertOk() ->assertDontSee('original-secret'); } public function test_local_password_is_used_when_shared_record_uses_another_app_key(): void { $connection = $this->connection('shared-secret'); $connection->update(['source_key' => 'jiansun']); $originalEncrypter = $this->app->make('encrypter'); try { $this->app->instance( 'encrypter', new Encrypter(str_repeat('x', 32), (string) config('app.cipher')) ); config([ 'upstreams.local_configuration.enabled' => true, 'jiansun.connection.host' => 'local-db.test', 'jiansun.connection.database' => 'local_database', 'jiansun.connection.username' => 'local_user', 'jiansun.connection.password' => 'local-secret', 'jiansun.connection.port' => 11433, ]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); $this->assertSame( 'local-secret', $sources->value('jiansun', 'connection.password') ); $this->assertSame('local-db.test', $sources->value('jiansun', 'connection.host')); $this->assertSame('local_database', $sources->value('jiansun', 'connection.database')); $this->assertSame('local_user', $sources->value('jiansun', 'connection.username')); $this->assertSame(11433, $sources->value('jiansun', 'connection.port')); } finally { $this->app->instance('encrypter', $originalEncrypter); } } public function test_private_override_is_honored_when_explicitly_enabled_in_production(): void { $connection = $this->connection('shared-secret'); $connection->update(['source_key' => 'jiansun']); $this->app->detectEnvironment(fn () => 'production'); try { config([ 'upstreams.local_configuration.enabled' => true, 'jiansun.connection.host' => 'private-tunnel.test', 'jiansun.connection.password' => 'private-secret', ]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); $this->assertSame( 'private-tunnel.test', $sources->value('jiansun', 'connection.host') ); $this->assertSame( 'private-secret', $sources->value('jiansun', 'connection.password') ); } finally { $this->app->detectEnvironment(fn () => 'testing'); } } public function test_shared_password_is_available_with_a_different_app_key(): void { $connection = $this->connection('shared-secret'); $connection->update(['source_key' => 'jiansun']); $originalEncrypter = $this->app->make('encrypter'); try { $this->app->instance( 'encrypter', new Encrypter(str_repeat('x', 32), (string) config('app.cipher')) ); config(['upstreams.local_configuration.enabled' => false]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); // 兼容尚未重建的旧本地镜像:旧模块仍直接读取 password 属性。 $this->assertSame('shared-secret', $connection->fresh()->password); $this->assertSame( 'shared-secret', $sources->value('jiansun', 'connection.password') ); } finally { $this->app->instance('encrypter', $originalEncrypter); } } public function test_complete_connection_uses_shared_fields_and_local_runtime_options(): void { $connection = $this->connection('shared-secret'); $connection->update([ 'source_key' => 'jiansun', 'host' => 'shared-db.test', 'port' => 14330, 'database_name' => 'shared_database', 'username' => 'shared_reader', 'odbc_driver' => 'Stored Driver', ]); config([ 'upstreams.local_configuration.enabled' => false, 'jiansun.connection' => [ 'host' => null, 'port' => 1433, 'database' => null, 'username' => null, 'password' => null, 'odbc_driver' => 'FreeTDS', 'tds_version' => '7.4', 'encryption' => 'off', 'connect_timeout' => 5, 'query_timeout' => 15, 'application_intent' => 'ReadOnly', ], ]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); $resolved = $sources->value('jiansun', 'connection', []); $this->assertSame('shared-db.test', $resolved['host']); $this->assertSame(14330, $resolved['port']); $this->assertSame('shared_database', $resolved['database']); $this->assertSame('shared_reader', $resolved['username']); $this->assertSame('shared-secret', $resolved['password']); $this->assertSame('FreeTDS', $resolved['odbc_driver']); $this->assertSame('off', $resolved['encryption']); $this->assertSame('ReadOnly', $resolved['application_intent']); } public function test_production_tunnel_replaces_only_the_shared_connection_endpoint(): void { $connection = $this->connection('shared-secret'); $connection->update([ 'source_key' => 'operations_replica', 'host' => 'shared-db.test', 'port' => 14330, 'database_name' => 'shared_database', 'username' => 'shared_reader', ]); $this->app->detectEnvironment(fn () => 'production'); try { config([ 'upstreams.local_configuration.enabled' => false, 'upstreams.stored_configuration.enabled' => true, 'upstreams.tunnel_endpoints.operations_replica' => [ 'host' => 'private-tunnel.test', 'port' => 15433, ], ]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); $resolved = $sources->value('operations_replica', 'connection', []); $this->assertSame('private-tunnel.test', $resolved['host']); $this->assertSame(15433, $resolved['port']); $this->assertSame('shared_database', $resolved['database']); $this->assertSame('shared_reader', $resolved['username']); $this->assertSame('shared-secret', $resolved['password']); } finally { $this->app->detectEnvironment(fn () => 'testing'); } } public function test_global_wildcard_scope_is_rejected(): void { $this->withExceptionHandling(); $this->loginAs() ->post(route('system-settings.databases.store'), $this->payload([ 'allowed_scopes' => '*', ])) ->assertSessionHasErrors('allowed_scopes'); $this->assertDatabaseMissing('system_database_connections', ['name' => '测试云数据库']); } public function test_regular_admin_can_open_database_settings(): void { $this->user->roles()->sync([ Role::where('name', 'admin')->firstOrFail()->id, ]); $this->loginAs() ->get(route('system-settings.databases.index')) ->assertOk() ->assertSeeText('财务系统配置数据库') ->assertSeeText('共享 · 已连接') ->assertSeeText('云数据库'); $this->get(route('system-settings.databases.system.edit')) ->assertOk() ->assertSeeText('连接系统数据库') ->assertSee('name="password"', false) ->assertDontSee('name="host"', false) ->assertDontSeeText('要求 TLS 加密连接') ->assertDontSeeText('CA 证书路径'); } public function test_database_page_separates_system_and_cloud_database_configuration(): void { $connectionName = (string) config('database.default'); config(["database.connections.$connectionName.password" => 'system-database-test-secret']); $this->loginAs() ->get(route('system-settings.databases.index')) ->assertOk() ->assertSee('data-system-database="fixed"', false) ->assertSeeTextInOrder([ '系统数据库', '财务系统配置数据库', '共享 · 已连接', '云数据库', '添加云数据库', ]); $this->get(route('system-settings.databases.system.edit')) ->assertOk() ->assertSeeText('连接系统数据库') ->assertDontSee('name="host"', false) ->assertDontSee('name="tls_required"', false) ->assertDontSee('system-database-test-secret'); } public function test_shared_data_dictionary_lists_objects_and_columns_without_upstream_credentials(): void { UpstreamSchemaColumn::query()->create([ 'source_key' => 'operations_replica', 'schema_name' => 'rpt', 'object_name' => 'finance_summary', 'object_type' => 'view', 'column_name' => 'company_code', 'ordinal_position' => 1, 'data_type' => 'nvarchar', 'max_length' => 50, 'numeric_precision' => null, 'numeric_scale' => null, 'is_nullable' => false, 'synchronized_at' => now(), ]); $this->loginAs() ->get(route('system-settings.databases.dictionary')) ->assertOk() ->assertSeeText('上游数据字典') ->assertSeeText('finance_summary') ->assertSeeText('1 个字段') ->assertDontSeeText('数据库密码'); $this->get(route('system-settings.databases.dictionary', [ 'selected_source' => 'operations_replica', 'selected_schema' => 'rpt', 'selected_object' => 'finance_summary', ])) ->assertOk() ->assertSeeText('company_code') ->assertSeeText('nvarchar'); } public function test_cloud_database_create_page_opens_inside_system_settings(): void { $this->loginAs() ->get(route('system-settings.databases.create')) ->assertOk() ->assertSeeText('添加数据库') ->assertDontSeeText('Not able to create a new user.'); } public function test_production_stored_configuration_requires_explicit_private_flag(): void { $connection = $this->connection('production-secret'); $connection->update(['source_key' => 'jiansun']); $originalEnvironment = $this->app->environment(); try { $this->app['env'] = 'production'; config(['upstreams.stored_configuration.enabled' => false]); $disabled = app(UpstreamStoredConfiguration::class); $this->assertFalse($disabled->editable()); $this->assertNull($disabled->find('jiansun')); config(['upstreams.stored_configuration.enabled' => true]); $enabled = new UpstreamStoredConfiguration(); $this->assertTrue($enabled->editable()); $this->assertSame($connection->id, $enabled->find('jiansun')?->id); } finally { $this->app['env'] = $originalEnvironment; } } public function test_bootstrap_page_is_not_presented_as_shared_database(): void { config([ 'system_database.mode' => 'bootstrap', 'upstreams.stored_configuration.enabled' => false, ]); $this->loginAs() ->get(route('system-settings.databases.index')) ->assertOk() ->assertSeeText('本地临时引导库') ->assertSeeText('临时 · 待切换') ->assertDontSeeText('添加云数据库'); } public function test_first_switch_only_requires_system_database_password(): void { config(['system_database.mode' => 'bootstrap']); $this->withExceptionHandling(); $this->loginAs() ->patch(route('system-settings.databases.system.update'), [ 'password' => '', ]) ->assertSessionHasErrors('password'); } public function test_wrong_system_database_password_stays_on_the_form(): void { config([ 'system_database.mode' => 'bootstrap', 'system_database.team_profile' => null, ]); $this->withExceptionHandling(); $this->loginAs() ->from(route('system-settings.databases.system.edit')) ->patch(route('system-settings.databases.system.update'), [ 'password' => 'definitely-not-the-team-password', ]) ->assertRedirect(route('system-settings.databases.system.edit')) ->assertSessionHasErrors([ 'password' => '系统数据库密码不正确,请重新输入。', ]); } public function test_successful_system_database_switch_invalidates_bootstrap_session(): void { $configuration = Mockery::mock(SystemDatabaseConfiguration::class); $configuration->shouldReceive('probe')->once()->andReturn([ 'cipher' => 'TLS_AES_256_GCM_SHA384', 'user_count' => 7, 'admin_count' => 2, 'source_count' => 2, 'prefix' => 'ak_', ]); $configuration->shouldReceive('persist')->once(); $this->app->instance(SystemDatabaseConfiguration::class, $configuration); $teamProfile = Mockery::mock(TeamSystemDatabaseProfile::class); $teamProfile->shouldReceive('settings')->once()->with('new-shared-secret')->andReturn([ 'driver' => 'mysql', 'host' => 'shared-db.test', 'port' => 3306, 'database' => 'shared_system', 'username' => 'shared_writer', 'password' => 'new-shared-secret', 'tls_required' => true, 'tls_verify_server_cert' => false, 'tls_ca' => '', ]); $this->app->instance(TeamSystemDatabaseProfile::class, $teamProfile); $this->loginAs() ->patch(route('system-settings.databases.system.update'), [ 'password' => 'new-shared-secret', ]) ->assertRedirect(route('login')); $this->assertGuest(); } public function test_unexpected_system_database_error_returns_to_form_instead_of_500(): void { $this->withExceptionHandling(); $configuration = Mockery::mock(SystemDatabaseConfiguration::class); $configuration->shouldReceive('probe')->once()->andThrow(new \LogicException('internal detail')); $this->app->instance(SystemDatabaseConfiguration::class, $configuration); $teamProfile = Mockery::mock(TeamSystemDatabaseProfile::class); $teamProfile->shouldReceive('settings')->once()->andReturn([ 'driver' => 'mysql', 'host' => 'shared-db.test', 'port' => 3306, 'database' => 'shared_system', 'username' => 'shared_writer', 'password' => 'new-shared-secret', 'tls_required' => true, 'tls_verify_server_cert' => false, 'tls_ca' => '', ]); $this->app->instance(TeamSystemDatabaseProfile::class, $teamProfile); $this->loginAs() ->from(route('system-settings.databases.system.edit')) ->patch(route('system-settings.databases.system.update'), [ 'password' => 'new-shared-secret', ]) ->assertRedirect(route('system-settings.databases.system.edit')) ->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([ 'name' => '测试云数据库', 'source_key' => '', 'host' => '127.0.0.1', 'port' => 1433, 'database_name' => 'test_finance', 'username' => 'readonly_user', 'password' => 'secret', 'odbc_driver' => 'FreeTDS', 'tds_version' => '7.4', 'connect_timeout' => 3, 'query_timeout' => 8, 'allowed_scopes' => 'supplier.options', 'enabled' => '1', ], $overrides); } private function connection(string $password): DatabaseConnection { return DatabaseConnection::create([ 'company_id' => 0, 'source_key' => null, 'name' => '已有云数据库', 'driver' => 'sqlsrv', 'host' => '127.0.0.1', 'port' => 1433, 'database_name' => 'test_finance', 'username' => 'readonly_user', 'password' => $password, 'odbc_driver' => 'FreeTDS', 'tds_version' => '7.4', 'connect_timeout' => 3, 'query_timeout' => 8, 'allowed_scopes' => ['supplier.options'], 'charset' => 'UTF-8', 'enabled' => true, ]); } }