feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements

Introduce per-tenant override for helpdesk BC connection config.
New table helpdesk_tenant_settings stores tenant-specific credentials
with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService
resolves global vs tenant config per request. Settings UI extended with
tenant override tab, toggle, and dual editing mode.

All runtime consumers (OData, SOAP, OAuth) read through the new
resolver. Token cache flushed on any config change. Default behavior
unchanged — tenants use global config until override explicitly enabled.

Also includes risk radar UI refinements: Stripe-style card redesign
with accent borders and score pills, removal of redundant KPI tiles
and search, and filtering of zero-score entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:29:59 +02:00
parent 97dbc7f57b
commit ea786f5341
27 changed files with 1150 additions and 109 deletions

View File

@@ -5,14 +5,14 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use PHPUnit\Framework\TestCase;
class BcODataGatewayTest extends TestCase
{
private function createGatewayWithSettings(bool $isConfigured = true, ?SessionStoreInterface $sessionStore = null): BcODataGateway
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('isConfigured')->willReturn($isConfigured);
$settings->method('getODataBaseUrl')->willReturn('https://bc.example.com/ODataV4');
$settings->method('getCompanyName')->willReturn('Test Company');

View File

@@ -5,6 +5,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use PHPUnit\Framework\TestCase;
@@ -15,7 +16,7 @@ class BcSoapGatewayTest extends TestCase
*/
private function createGateway(array $response): BcSoapGateway
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('isConfigured')->willReturn(true);
$settings->method('getODataBaseUrl')->willReturn('https://bc.example.com:7048/BusinessCentral-NUP/ODataV4');
$settings->method('getCompanyName')->willReturn('Test Company');
@@ -34,7 +35,7 @@ class BcSoapGatewayTest extends TestCase
* @param array{http_code:int,body:string,error:string} $fakeResponse
*/
public function __construct(
HelpdeskSettingsGateway $settings,
EffectiveHelpdeskSettingsService $settings,
HelpdeskOAuthTokenService $oauthTokenService,
SessionStoreInterface $sessionStore,
private readonly array $fakeResponse

View File

@@ -4,7 +4,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use PHPUnit\Framework\TestCase;
class DebitorDetailServiceTest extends TestCase
@@ -12,7 +12,7 @@ class DebitorDetailServiceTest extends TestCase
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorDetailService
{
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('isConfigured')->willReturn($isConfigured);
return new DebitorDetailService($gateway, $settings);

View File

@@ -4,7 +4,7 @@ namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use PHPUnit\Framework\TestCase;
class DebitorSearchServiceTest extends TestCase
@@ -12,7 +12,7 @@ class DebitorSearchServiceTest extends TestCase
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorSearchService
{
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('isConfigured')->willReturn($isConfigured);
return new DebitorSearchService($gateway, $settings);

View File

@@ -0,0 +1,140 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
use PHPUnit\Framework\TestCase;
class EffectiveHelpdeskSettingsServiceTest extends TestCase
{
private function createResolver(
int $tenantId = 0,
bool $overrideEnabled = false,
?string $tenantAuthMode = null,
?string $tenantOdataUrl = null,
?string $tenantCompanyName = null,
): EffectiveHelpdeskSettingsService {
$globalGateway = $this->createMock(HelpdeskSettingsGateway::class);
$globalGateway->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
$globalGateway->method('getODataBaseUrl')->willReturn('https://global.example.com/ODataV4');
$globalGateway->method('getCompanyName')->willReturn('Global Company');
$globalGateway->method('getBasicUser')->willReturn('global-user');
$globalGateway->method('getBasicPassword')->willReturn('global-pass');
$globalGateway->method('getOAuthTenantId')->willReturn(null);
$globalGateway->method('getOAuthClientId')->willReturn(null);
$globalGateway->method('getOAuthClientSecret')->willReturn(null);
$globalGateway->method('getOAuthTokenEndpoint')->willReturn(null);
$tenantGateway = $this->createMock(HelpdeskTenantSettingsGateway::class);
$tenantGateway->method('isOverrideEnabled')->willReturn($overrideEnabled);
$tenantGateway->method('getAuthMode')->willReturn($tenantAuthMode ?? HelpdeskSettingsGateway::AUTH_MODE_OAUTH2);
$tenantGateway->method('getODataBaseUrl')->willReturn($tenantOdataUrl ?? 'https://tenant.example.com/ODataV4');
$tenantGateway->method('getCompanyName')->willReturn($tenantCompanyName ?? 'Tenant Company');
$tenantGateway->method('getBasicUser')->willReturn('tenant-user');
$tenantGateway->method('getBasicPassword')->willReturn('tenant-pass');
$tenantGateway->method('getOAuthTenantId')->willReturn('azure-tenant-123');
$tenantGateway->method('getOAuthClientId')->willReturn('client-abc');
$tenantGateway->method('getOAuthClientSecret')->willReturn('secret-xyz');
$tenantGateway->method('getOAuthTokenEndpoint')->willReturn('https://login.microsoftonline.com/tenant/oauth2/v2.0/token');
$tenantGateway->method('getSystemRecommendationsConfig')->willReturn(null);
$tenantGateway->method('getControllingRiskConfig')->willReturn(null);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([
'current_tenant' => $tenantId > 0 ? ['id' => $tenantId] : [],
]);
return new EffectiveHelpdeskSettingsService($globalGateway, $tenantGateway, $sessionStore);
}
public function testFallsBackToGlobalWhenNoTenantInSession(): void
{
$resolver = $this->createResolver(tenantId: 0);
$settings = $resolver->resolveForCurrentTenant();
$this->assertSame('global', $settings->source);
$this->assertSame('https://global.example.com/ODataV4', $settings->odataBaseUrl);
$this->assertSame('Global Company', $settings->companyName);
$this->assertSame('global-user', $settings->basicUser);
}
public function testFallsBackToGlobalWhenOverrideDisabled(): void
{
$resolver = $this->createResolver(tenantId: 5, overrideEnabled: false);
$settings = $resolver->resolveForCurrentTenant();
$this->assertSame('global', $settings->source);
$this->assertSame('https://global.example.com/ODataV4', $settings->odataBaseUrl);
$this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_BASIC, $settings->authMode);
}
public function testUsesTenantConfigWhenOverrideEnabled(): void
{
$resolver = $this->createResolver(tenantId: 5, overrideEnabled: true);
$settings = $resolver->resolveForCurrentTenant();
$this->assertSame('tenant', $settings->source);
$this->assertSame('https://tenant.example.com/ODataV4', $settings->odataBaseUrl);
$this->assertSame('Tenant Company', $settings->companyName);
$this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, $settings->authMode);
$this->assertSame('client-abc', $settings->oauthClientId);
$this->assertSame('secret-xyz', $settings->oauthClientSecret);
}
public function testResolveForTenantDirectly(): void
{
$resolver = $this->createResolver(tenantId: 0, overrideEnabled: true);
// Calling resolveForTenant directly with a tenant ID, ignoring session
$settings = $resolver->resolveForTenant(5);
$this->assertSame('tenant', $settings->source);
$this->assertSame('Tenant Company', $settings->companyName);
}
public function testResolveForTenantFallsBackForZeroId(): void
{
$resolver = $this->createResolver(tenantId: 0, overrideEnabled: true);
$settings = $resolver->resolveForTenant(0);
$this->assertSame('global', $settings->source);
}
public function testProxyMethodsReturnResolvedValues(): void
{
$resolver = $this->createResolver(tenantId: 5, overrideEnabled: true);
$this->assertSame(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, $resolver->getAuthMode());
$this->assertSame('https://tenant.example.com/ODataV4', $resolver->getODataBaseUrl());
$this->assertSame('Tenant Company', $resolver->getCompanyName());
$this->assertSame('client-abc', $resolver->getOAuthClientId());
$this->assertSame('secret-xyz', $resolver->getOAuthClientSecret());
$this->assertTrue($resolver->isConfigured());
}
public function testBuildEntityUrlUsesResolvedConfig(): void
{
$resolver = $this->createResolver(tenantId: 5, overrideEnabled: true, tenantCompanyName: 'Acme');
$url = $resolver->buildEntityUrl('Customers');
$this->assertStringContainsString('tenant.example.com', $url);
$this->assertStringContainsString('Acme', $url);
$this->assertStringContainsString('Customers', $url);
}
public function testValueObjectIsConfiguredWithBasicAuth(): void
{
$resolver = $this->createResolver(
tenantId: 5,
overrideEnabled: true,
tenantAuthMode: HelpdeskSettingsGateway::AUTH_MODE_BASIC,
);
$settings = $resolver->resolveForCurrentTenant();
// Tenant basic user/password are set, so it should be configured
$this->assertTrue($settings->isConfigured());
$this->assertSame([], $settings->validateConfiguration());
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
@@ -11,7 +12,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase
{
private function createService(?string $authMode = null, ?string $cachedToken = null): HelpdeskOAuthTokenService
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('getAuthMode')->willReturn($authMode ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC);
$tokenRepo = $this->createMock(HelpdeskTokenRepository::class);
@@ -46,7 +47,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase
public function testDoesNotQueryCacheWhenAuthModeIsBasic(): void
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
$tokenRepo = $this->createMock(HelpdeskTokenRepository::class);
@@ -58,7 +59,7 @@ class HelpdeskOAuthTokenServiceTest extends TestCase
public function testDoesNotQueryCacheForInvalidTenantId(): void
{
$settings = $this->createMock(HelpdeskSettingsGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2);
$tokenRepo = $this->createMock(HelpdeskTokenRepository::class);