superuser()->create(); $this->actingAs($user); return $user; } private function ensureSetting(array $overrides = []): Setting { // Reset the Setting singleton cache. Setting::getSettings() // memoizes into a public static \$_cache, and any prior test // (or the framework boot) that touched Setting::first() before // this test's factory ran will otherwise hand back a stale row. Setting::$_cache = null; $setting = Setting::first() ?? Setting::factory()->create(); // Setting's \$fillable is restrictive (site_name, email_domain, // and a handful of others). LDAP fields are NOT fillable, so // fill() / factory create() would silently drop them. forceFill // + save writes them directly. if ($overrides) { $setting->forceFill($overrides)->save(); $setting = $setting->fresh(); } // Prime the singleton cache with the row we just wrote so any // in-test call to Setting::getSettings() picks up our values. Setting::$_cache = $setting; return $setting; } // === Authorization ===================================================== public function test_mount_forbids_non_superadmin(): void { $this->actingAs(User::factory()->create()); $this->ensureSetting(); // Livewire captures the abort(403) from mount() and surfaces it // as a 403 status on the testable rather than rethrowing the // HttpException. Match the wire test harness's shape, not the // raw exception class. Livewire::test(LdapSettings::class) ->assertStatus(403); } public function test_mount_allows_superadmin(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->assertStatus(200); } // === Mount / initial state ============================================= public function test_mount_hydrates_persisted_settings_into_props(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_enabled' => 1, 'ldap_server' => 'ldaps://ldap.example.com', 'is_ad' => 1, 'ad_domain' => 'example.com', 'ldap_uname' => 'cn=admin,dc=example,dc=com', 'ldap_basedn' => 'ou=users,dc=example,dc=com', 'ldap_username_field' => 'samaccountname', 'ldap_fname_field' => 'givenname', ]); Livewire::test(LdapSettings::class) ->assertSet('ldap_enabled', true) ->assertSet('ldap_server', 'ldaps://ldap.example.com') ->assertSet('is_ad', true) ->assertSet('ad_domain', 'example.com') ->assertSet('ldap_uname', 'cn=admin,dc=example,dc=com') ->assertSet('ldap_basedn', 'ou=users,dc=example,dc=com') ->assertSet('ldap_username_field', 'samaccountname') ->assertSet('ldap_fname_field', 'givenname'); } public function test_mount_never_hydrates_persisted_password(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_pword' => Crypt::encrypt('supersecret'), 'ldap_uname' => 'cn=admin,dc=example,dc=com', ]); // Even with an encrypted password on disk, the Livewire prop // must be empty so the plaintext never crosses the wire. Livewire::test(LdapSettings::class) ->assertSet('ldap_pword', ''); } // === Wizard navigation ================================================= public function test_go_to_step_forward_blocked_past_highest_reached(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->assertSet('currentStep', 1) ->call('goToStep', 3) ->assertSet('currentStep', 1); } public function test_go_to_step_backward_allowed(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 3) ->set('currentStep', 3) ->call('goToStep', 2) ->assertSet('currentStep', 2); } public function test_go_to_step_reverts_dirty_props_to_persisted(): void { $this->actAsSuperuser(); // Persisted state has the mapping fields filled in. $this->ensureSetting([ 'ldap_username_field' => 'uid', 'ldap_fname_field' => 'givenname', 'ldap_email' => 'mail', ]); Livewire::test(LdapSettings::class) ->set('highestStepReached', 3) ->set('currentStep', 3) // Simulate the user clearing fields (this flips dirty=true // via the updated() hook). ->set('ldap_email', '') ->set('ldap_fname_field', '') ->assertSet('dirty', true) // User clicks the stepper to jump to step 1. The confirm // dialog is client-side, so the server-side goToStep call // is what actually fires. ->call('goToStep', 1) ->assertSet('currentStep', 1) ->assertSet('dirty', false) // Cleared fields should be restored from the persisted row // so walking back forward and saving does not silently // overwrite them with empties. ->assertSet('ldap_email', 'mail') ->assertSet('ldap_fname_field', 'givenname') ->assertSet('ldap_username_field', 'uid'); } public function test_url_step_param_clamps_to_highest_reached_on_mount(): void { $this->actAsSuperuser(); $this->ensureSetting(); // Simulate ?step=4 with no session progress. Should clamp to 1. Livewire::withQueryParams(['step' => 4]) ->test(LdapSettings::class) ->assertSet('currentStep', 1); } public function test_mount_lands_on_step_5_when_ldap_already_enabled(): void { $this->actAsSuperuser(); $this->ensureSetting(['ldap_enabled' => 1]); // No URL step param. LDAP is on. Should land on the completion // screen (step 5) with the full wizard unlocked. Livewire::test(LdapSettings::class) ->assertSet('currentStep', 5) ->assertSet('highestStepReached', 5); } public function test_mount_honors_explicit_url_step_when_ldap_enabled(): void { $this->actAsSuperuser(); $this->ensureSetting(['ldap_enabled' => 1]); // Explicit ?step=2 with ldap_enabled=1 should NOT get bumped up // to step 5. Return visitors need to be able to jump back to // earlier steps to edit config. Livewire::withQueryParams(['step' => 2]) ->test(LdapSettings::class) ->assertSet('currentStep', 2) ->assertSet('highestStepReached', 5); } public function test_mount_starts_at_step_1_when_ldap_disabled(): void { $this->actAsSuperuser(); $this->ensureSetting(['ldap_enabled' => 0]); Livewire::test(LdapSettings::class) ->assertSet('currentStep', 1) ->assertSet('highestStepReached', 1); } // === Step 1 syntax ===================================================== public function test_step1_requires_ldap_server(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', '') ->call('saveAndAdvance') ->assertHasErrors(['ldap_server' => 'required']); } public function test_step1_ldap_server_must_start_with_ldap_scheme(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', 'https://example.com') ->call('saveAndAdvance') ->assertHasErrors(['ldap_server' => 'starts_with']); } public function test_step1_ad_domain_required_when_is_ad_checked(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://ldap.example.com') ->set('is_ad', true) ->set('ad_domain', '') ->call('saveAndAdvance') ->assertHasErrors(['ad_domain']); } public function test_step1_tls_pair_xor_key_without_cert_blocks_advance(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://ldap.example.com') ->set('ldap_client_tls_key', "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----") ->set('ldap_client_tls_cert', '') // canAdvance false because of the XOR rule, so no click reaches // saveStep1 anyway. Assert via canAdvance directly. ->assertSet('canAdvance', false); } public function test_step1_tls_key_junk_pem_rejected(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://ldap.example.com') ->set('ldap_client_tls_key', 'not a real pem') ->set('ldap_client_tls_cert', 'also not a real pem') ->call('saveAndAdvance') ->assertHasErrors(['ldap_client_tls_key', 'ldap_client_tls_cert']); } // === Step 1 SSRF filter ================================================ public function test_step1_rejects_localhost_as_private_ip(): void { $this->actAsSuperuser(); $this->ensureSetting(); config(['app.test_allow_private_ips' => false]); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://localhost') ->call('saveAndAdvance') ->assertHasErrors(['ldap_server']); } public function test_step1_rejects_rfc1918_address(): void { $this->actAsSuperuser(); $this->ensureSetting(); config(['app.test_allow_private_ips' => false]); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://192.168.1.10') ->call('saveAndAdvance') ->assertHasErrors(['ldap_server']); } public function test_step1_rejects_cloud_metadata_address(): void { $this->actAsSuperuser(); $this->ensureSetting(); config(['app.test_allow_private_ips' => false]); Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://169.254.169.254') ->call('saveAndAdvance') ->assertHasErrors(['ldap_server']); } // === Step 1 rate limit ================================================= public function test_step1_rate_limit_blocks_after_ten_attempts(): void { $user = $this->actAsSuperuser(); $this->ensureSetting(); // Hit the same rate-limit key the wizard uses so we exhaust the // budget without needing to spam real save calls. $key = 'ldap-test-step1:'.$user->id; for ($i = 0; $i < 10; $i++) { RateLimiter::hit($key, 60); } // The 11th test would fail rate-limit. Use a routable-looking // public host so the code reaches the rate-limit check (it // runs after IP policy for private-IP-safe servers, but // rate limit is checked BEFORE the LDAP connect). Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://ldap.forumsys.com') ->call('saveAndAdvance') ->assertSet('testStatus', 'error'); } // === Step 2 syntax + password reuse ==================================== public function test_step2_requires_bind_username_basedn_and_auth_filter(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', '') ->set('ldap_pword', '') ->set('ldap_basedn', '') ->set('ldap_auth_filter_query', '') ->call('saveAndAdvance') ->assertHasErrors(['ldap_uname', 'ldap_pword', 'ldap_basedn', 'ldap_auth_filter_query']); } public function test_step2_password_optional_when_uname_unchanged_and_persisted(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_uname' => 'cn=admin,dc=example,dc=com', 'ldap_pword' => Crypt::encrypt('persistedsecret'), ]); // Uname matches persisted, pword left blank on the form. The // password rule should not fire. Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=admin,dc=example,dc=com') ->set('ldap_pword', '') ->set('ldap_basedn', 'ou=users,dc=example,dc=com') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasNoErrors(['ldap_pword']); } public function test_step2_password_required_when_uname_changed(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_uname' => 'cn=oldadmin,dc=example,dc=com', 'ldap_pword' => Crypt::encrypt('persistedsecret'), ]); Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=newadmin,dc=example,dc=com') ->set('ldap_pword', '') ->set('ldap_basedn', 'ou=users,dc=example,dc=com') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasErrors(['ldap_pword']); } public function test_step2_base_dn_equal_to_bind_dn_rejected(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=read-only-admin,dc=example,dc=com') ->set('ldap_pword', 'anything') ->set('ldap_basedn', 'cn=read-only-admin,dc=example,dc=com') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasErrors(['ldap_basedn']); } public function test_step2_base_dn_equal_to_bind_dn_normalizes_case_and_whitespace(): void { $this->actAsSuperuser(); $this->ensureSetting(); // Different case + varying whitespace around commas should still // trip the guard, since directories are case-insensitive on DN // comparisons and comma-adjacent whitespace is not significant. Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=admin,dc=example,dc=com') ->set('ldap_pword', 'anything') ->set('ldap_basedn', 'CN=Admin, DC=Example, DC=Com') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasErrors(['ldap_basedn']); } public function test_step2_base_dn_that_is_a_parent_of_bind_dn_is_allowed(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_uname' => 'cn=admin,dc=example,dc=com', 'ldap_pword' => Crypt::encrypt('persistedsecret'), ]); // The normal, correct relationship: bind DN lives under the base // DN. Should pass the base-DN-equals-bind-DN closure. Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=admin,dc=example,dc=com') ->set('ldap_pword', '') ->set('ldap_basedn', 'dc=example,dc=com') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasNoErrors(['ldap_basedn']); } public function test_step2_filter_with_leading_paren_rejected(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_uname' => 'cn=admin,dc=example,dc=com', 'ldap_pword' => Crypt::encrypt('persistedsecret'), ]); Livewire::test(LdapSettings::class) ->set('highestStepReached', 2) ->set('currentStep', 2) ->set('ldap_uname', 'cn=admin,dc=example,dc=com') ->set('ldap_pword', '') ->set('ldap_basedn', 'ou=users,dc=example,dc=com') ->set('ldap_filter', '(cn=*)') ->set('ldap_auth_filter_query', 'uid=') ->call('saveAndAdvance') ->assertHasErrors(['ldap_filter']); } // === Step 3 syntax ===================================================== public function test_step3_requires_username_and_first_name_mapping(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 3) ->set('currentStep', 3) ->set('ldap_username_field', '') ->set('ldap_fname_field', '') ->call('saveAndAdvance') ->assertHasErrors(['ldap_username_field', 'ldap_fname_field']); } public function test_step3_username_field_rejects_camelcase_samaccountname(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 3) ->set('currentStep', 3) ->set('ldap_username_field', 'sAMAccountName') ->set('ldap_fname_field', 'givenname') ->call('saveAndAdvance') ->assertHasErrors(['ldap_username_field']); } // === Step 4 business logic ============================================= public function test_step4_forces_ldap_enabled_true_on_save(): void { $this->actAsSuperuser(); $this->ensureSetting(['ldap_enabled' => 0]); Livewire::test(LdapSettings::class) ->set('highestStepReached', 4) ->set('currentStep', 4) ->set('ldap_default_group', null) ->set('custom_forgot_pass_url', '') ->call('saveAndAdvance'); $this->assertSame('1', Setting::getSettings()->ldap_enabled); } public function test_step4_advances_to_completion_step_after_save(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 4) ->set('currentStep', 4) ->set('ldap_default_group', null) ->call('saveAndAdvance') ->assertSet('currentStep', 5) ->assertSet('highestStepReached', 5); } public function test_step4_default_group_must_exist_in_permission_groups(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 4) ->set('currentStep', 4) ->set('ldap_default_group', 999_999) ->call('saveAndAdvance') ->assertHasErrors(['ldap_default_group']); } public function test_step4_default_group_accepts_real_group_id(): void { $this->actAsSuperuser(); $this->ensureSetting(); $group = Group::factory()->create(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 4) ->set('currentStep', 4) ->set('ldap_default_group', $group->id) ->call('saveAndAdvance') ->assertHasNoErrors(['ldap_default_group']); $this->assertSame($group->id, (int) Setting::getSettings()->ldap_default_group); } public function test_step4_custom_forgot_pass_url_must_be_valid_url(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('highestStepReached', 4) ->set('currentStep', 4) ->set('custom_forgot_pass_url', 'not a url') ->call('saveAndAdvance') ->assertHasErrors(['custom_forgot_pass_url']); } // === disableLdap ======================================================= public function test_disable_ldap_flips_flag_and_redirects(): void { $this->actAsSuperuser(); $this->ensureSetting([ 'ldap_enabled' => 1, 'ldap_server' => 'ldaps://ldap.example.com', 'ldap_uname' => 'cn=admin,dc=example,dc=com', ]); Livewire::test(LdapSettings::class) ->call('disableLdap') ->assertRedirect(route('settings.index')); $fresh = Setting::getSettings(); $this->assertSame('0', (string) $fresh->ldap_enabled); // Other settings preserved so the wizard can re-enable later // without the user re-entering everything. $this->assertSame('ldaps://ldap.example.com', $fresh->ldap_server); $this->assertSame('cn=admin,dc=example,dc=com', $fresh->ldap_uname); } public function test_disable_ldap_clears_wizard_progress_session_key(): void { $user = $this->actAsSuperuser(); $this->ensureSetting(['ldap_enabled' => 1]); $key = 'ldap_wizard_highest_step:'.$user->id; session()->put($key, 4); Livewire::test(LdapSettings::class) ->call('disableLdap'); $this->assertFalse(session()->has($key)); } // === finishWizard ====================================================== public function test_finish_wizard_clears_progress_and_redirects(): void { $user = $this->actAsSuperuser(); $this->ensureSetting(); $key = 'ldap_wizard_highest_step:'.$user->id; session()->put($key, 5); Livewire::test(LdapSettings::class) ->call('finishWizard') ->assertRedirect(route('settings.index')); $this->assertFalse(session()->has($key)); } // === Audit log routing ================================================= public function test_test_run_writes_to_admin_log_channel_not_action_logs(): void { $this->actAsSuperuser(); $this->ensureSetting(); config(['app.test_allow_private_ips' => false]); // Spy the admin channel so we can assert against calls without // suppressing them entirely (info() would still hit file otherwise). Log::shouldReceive('channel')->with('admin')->andReturnSelf(); Log::shouldReceive('info') ->once() ->with('ldap_wizard.test', \Mockery::on(function ($context) { return isset($context['step'], $context['action_type'], $context['status'], $context['server'], $context['user_id'], $context['message']); })); // Trigger a step-1 test that will fail on the SSRF gate, which // still routes through recordFieldError -> writeTestAuditLog. Livewire::test(LdapSettings::class) ->set('ldap_server', 'ldap://localhost') ->call('saveAndAdvance'); // Belt-and-suspenders: nothing should have landed in action_logs // for this wizard action (only rows that pre-exist from factories // would be here). Assert the action_type wasn't written. $this->assertDatabaseMissing('action_logs', [ 'action_type' => 'ldap connection test', ]); } // === Trimming ========================================================== public function test_updated_trims_whitespace_padded_values(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->set('ldap_server', ' ldap://ldap.example.com ') ->assertSet('ldap_server', 'ldap://ldap.example.com') ->set('ldap_uname', "\tcn=admin,dc=example,dc=com\n") ->assertSet('ldap_uname', 'cn=admin,dc=example,dc=com'); } public function test_updated_marks_wizard_dirty(): void { $this->actAsSuperuser(); $this->ensureSetting(); Livewire::test(LdapSettings::class) ->assertSet('dirty', false) ->set('ldap_server', 'ldap://ldap.example.com') ->assertSet('dirty', true); } }