seed(RolePermissionSeeder::class); $tenant = app(TenantProvisioningService::class)->createWithOwner([ 'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin', 'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123', ]); $admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail(); $this->actingAs($admin)->put(route('device-settings.update'), [ 'base_username' => 'radiq-manager', 'base_password' => 'Device!Password123', 'base_password_confirmation' => 'Device!Password123', 'use_tls' => false, 'verify_tls' => true, 'connection_timeout' => 10, ])->assertRedirect(); $setting = TenantDeviceSetting::withoutGlobalScope('tenant')->firstOrFail(); $this->assertSame('Device!Password123', $setting->base_password); $this->assertNotSame('Device!Password123', DB::table('tenant_device_settings')->value('base_password')); $this->assertArrayNotHasKey('base_password', $setting->toArray()); } public function test_tenant_admin_must_confirm_login_password_to_reveal_base_password(): void { $this->seed(RolePermissionSeeder::class); $tenant = app(TenantProvisioningService::class)->createWithOwner([ 'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin', 'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123', ]); $admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail(); TenantDeviceSetting::create([ 'tenant_id' => $tenant->id, 'base_username' => 'radiq-manager', 'base_password' => 'Device!Password123', 'connection_timeout' => 10, ]); $this->actingAs($admin)->postJson(route('device-settings.reveal-password'), [ 'current_password' => 'wrong-password', ])->assertUnprocessable()->assertJsonValidationErrors('current_password'); $this->actingAs($admin)->postJson(route('device-settings.reveal-password'), [ 'current_password' => 'Strong!Password123', ])->assertOk()->assertExactJson(['password' => 'Device!Password123'])->assertHeader('Cache-Control', 'no-store, private'); } public function test_key_rotation_reencrypts_existing_secrets_without_changing_plaintext(): void { $this->seed(RolePermissionSeeder::class); $tenant = app(TenantProvisioningService::class)->createWithOwner([ 'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin', 'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123', ]); $admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail(); TenantDeviceSetting::create([ 'tenant_id' => $tenant->id, 'base_username' => 'radiq-manager', 'base_password' => 'Device!Password123', 'connection_timeout' => 10, ]); $before = DB::table('tenant_device_settings')->value('base_password'); $this->actingAs($admin)->post(route('device-settings.rotate-key'), [ 'mode' => 'generated', 'current_password' => 'Strong!Password123', ])->assertRedirect(); $after = DB::table('tenant_device_settings')->value('base_password'); $this->assertNotSame($before, $after); $this->assertSame('Device!Password123', TenantDeviceSetting::withoutGlobalScope('tenant')->firstOrFail()->base_password); $this->assertDatabaseCount('tenant_encryption_keys', 2); $this->assertDatabaseHas('tenant_encryption_keys', ['tenant_id' => $tenant->id, 'version' => 2, 'is_active' => true]); } public function test_mikrotik_groups_exclude_policy_and_base_user_uses_full(): void { $client = new FakeRouterOsClient; (new MikrotikDriver($client))->provisionBaseAccess('radiq-manager', 'Device!Password123'); $groups = collect($client->commands)->filter(fn (array $command) => $command[0] === '/user/group/add'); foreach (['RADIQ-READ', 'RADIQ-WRITE', 'RADIQ-NOC'] as $name) { $command = $groups->first(fn (array $item) => in_array('=name='.$name, $item, true)); $policy = collect($command)->first(fn (string $word) => str_starts_with($word, '=policy=')); $this->assertStringNotContainsString(',policy,', ','.str($policy)->after('=policy=').','); } $this->assertFalse($groups->contains(fn (array $item) => in_array('=name=RADIQ-MANAGER', $item, true))); $baseUser = collect($client->commands)->first(fn (array $item) => $item[0] === '/user/add'); $this->assertContains('=group=full', $baseUser); } public function test_mikrotik_inventory_and_legacy_cleanup_preserve_full_users(): void { $client = new FakeRouterOsClient; $client->responses['/system/identity/print'] = [['name' => 'CCR-Jakarta']]; $client->responses['/system/routerboard/print'] = [['model' => 'CCR2004', 'serial-number' => 'ABC123', 'current-firmware' => '7.20']]; $client->responses['/system/resource/print'] = [['version' => '7.20', 'architecture-name' => 'arm64']]; $client->responses['/user/print'] = [ ['.id' => '*1', 'name' => 'radiq-manager', 'group' => 'full'], ['.id' => '*2', 'name' => 'emergency-admin', 'group' => 'full'], ['.id' => '*3', 'name' => 'old-noc', 'group' => 'read'], ]; $driver = new MikrotikDriver($client); $this->assertSame('ABC123', $driver->getDeviceInfo()['serial-number']); $result = $driver->cleanupLegacyUsers('radiq-manager'); $this->assertSame(['old-noc'], $result['deleted']); $this->assertContains(['/user/remove', '=.id=*3'], $client->commands); $this->assertNotContains(['/user/remove', '=.id=*2'], $client->commands); } public function test_unsupported_device_fact_does_not_cancel_other_inventory(): void { $client = new FakeRouterOsClient; $client->responses['/system/identity/print'] = [['name' => 'Router ROS']]; $client->responses['/system/resource/print'] = [['version' => '6.49.17', 'uptime' => '1d']]; $client->failWhenProplistContains = 'cpu'; $facts = (new MikrotikDriver($client))->getDeviceInfo(); $this->assertSame('Router ROS', $facts['identity']); $this->assertSame('6.49.17', $facts['version']); $this->assertArrayNotHasKey('cpu', $facts); } } class FakeRouterOsClient extends RouterOsApiClient { public array $commands = []; public array $responses = []; public ?string $failWhenProplistContains = null; public function __construct() {} public function connect(): void {} public function disconnect(): void {} public function command(array $words): array { $this->commands[] = $words; if ($this->failWhenProplistContains && in_array('=.proplist='.$this->failWhenProplistContains, $words, true)) { throw new \RuntimeException('CONNECTION_TIMEOUT: unsupported fact'); } if (array_key_exists($words[0], $this->responses)) { return $this->responses[$words[0]]; } if (str_ends_with($words[0], '/print')) { return []; } return []; } }