agent foundation

This commit is contained in:
2026-03-06 00:44:52 +01:00
parent 9819cba733
commit 9a08f96c11
199 changed files with 8522 additions and 1880 deletions

View File

@@ -0,0 +1,137 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AgentSystemContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testAgentSystemJsonFilesAreValidJson(): void
{
$jsonFiles = [
'agent-system/checks/guard-catalog.json',
'agent-system/checks/quality-gates.json',
'agent-system/contracts/planner.schema.json',
'agent-system/contracts/executor.schema.json',
'agent-system/contracts/reviewer-guards.schema.json',
'agent-system/contracts/reviewer-acceptance.schema.json',
'agent-system/contracts/finalizer.schema.json',
'agent-system/templates/plan.template.json',
'agent-system/templates/execution-report.template.json',
'agent-system/templates/review-guards.template.json',
'agent-system/templates/review-acceptance.template.json',
'agent-system/templates/finalize.template.json',
];
foreach ($jsonFiles as $file) {
$decoded = json_decode($this->readProjectFile($file), true);
$this->assertIsArray($decoded, 'Invalid JSON: ' . $file);
}
}
public function testGuardCatalogIdsAreUniqueAndWellFormed(): void
{
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
$this->assertNotSame([], $guards, 'Guard catalog must not be empty.');
$ids = [];
foreach ($guards as $guard) {
$id = (string) ($guard['id'] ?? '');
$source = (string) ($guard['source'] ?? '');
$this->assertMatchesRegularExpression('/^GR-[A-Z]+-[0-9]{3}$/', $id, 'Invalid guard id: ' . $id);
$this->assertNotSame('', $source, 'Missing guard source for ' . $id);
$sourcePath = explode('#', $source)[0];
$this->readProjectFile($sourcePath);
$ids[] = $id;
}
$this->assertSame($ids, array_values(array_unique($ids)), 'Duplicate guard IDs found.');
}
public function testQualityGateIdsAreUniqueAndWellFormed(): void
{
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
$items = is_array($gates['gates'] ?? null) ? $gates['gates'] : [];
$this->assertNotSame([], $items, 'Quality gates must not be empty.');
$ids = [];
foreach ($items as $gate) {
$id = (string) ($gate['id'] ?? '');
$command = (string) ($gate['command'] ?? '');
$this->assertMatchesRegularExpression('/^QG-[0-9]{3}$/', $id, 'Invalid quality gate id: ' . $id);
$this->assertNotSame('', trim($command), 'Missing command for ' . $id);
$ids[] = $id;
}
$this->assertSame($ids, array_values(array_unique($ids)), 'Duplicate quality gate IDs found.');
}
public function testTemplatesReferenceKnownGuardAndGateIds(): void
{
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
$guardIds = array_map(
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
);
$gateIds = array_map(
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
);
$planTemplate = $this->decodeJson('agent-system/templates/plan.template.json');
$planGuardIds = is_array($planTemplate['guardrails']['required_guard_ids'] ?? null)
? $planTemplate['guardrails']['required_guard_ids']
: [];
$planGateIds = is_array($planTemplate['guardrails']['required_quality_gate_ids'] ?? null)
? $planTemplate['guardrails']['required_quality_gate_ids']
: [];
foreach ($planGuardIds as $id) {
$this->assertContains($id, $guardIds, 'Unknown guard id in plan template: ' . (string) $id);
}
foreach ($planGateIds as $id) {
$this->assertContains($id, $gateIds, 'Unknown gate id in plan template: ' . (string) $id);
}
$reviewGuardsTemplate = $this->decodeJson('agent-system/templates/review-guards.template.json');
$reviewGuardIds = is_array($reviewGuardsTemplate['checked_guard_ids'] ?? null)
? $reviewGuardsTemplate['checked_guard_ids']
: [];
$reviewGateIds = is_array($reviewGuardsTemplate['checked_quality_gate_ids'] ?? null)
? $reviewGuardsTemplate['checked_quality_gate_ids']
: [];
foreach ($reviewGuardIds as $id) {
$this->assertContains($id, $guardIds, 'Unknown guard id in review template: ' . (string) $id);
}
foreach ($reviewGateIds as $id) {
$this->assertContains($id, $gateIds, 'Unknown gate id in review template: ' . (string) $id);
}
}
public function testRolePromptsReferenceGuardCatalogAndQualityGates(): void
{
$plannerPrompt = $this->readProjectFile('agent-system/prompts/planner.md');
$executorPrompt = $this->readProjectFile('agent-system/prompts/executor.md');
$reviewerPrompt = $this->readProjectFile('agent-system/prompts/reviewer-guards.md');
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $plannerPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $plannerPrompt);
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $executorPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $executorPrompt);
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $reviewerPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $reviewerPrompt);
}
/**
* @return array<string,mixed>
*/
private function decodeJson(string $path): array
{
$decoded = json_decode($this->readProjectFile($path), true);
$this->assertIsArray($decoded, 'Invalid JSON in ' . $path);
return $decoded;
}
}

View File

@@ -12,9 +12,6 @@ class CodexSkillsContractTest extends TestCase
{
$skill = $this->readProjectFile('tools/codex-skills/core-guardrails/SKILL.md');
$this->assertStringContainsString('name: core-guardrails', $skill);
$this->assertStringContainsString('MUST', $skill);
$this->assertStringContainsString('MUST NOT', $skill);
$this->assertStringContainsString('Out of Scope', $skill);
$this->readProjectFile('tools/codex-skills/core-guardrails/references/boundaries-core.md');
$this->readProjectFile('tools/codex-skills/core-guardrails/references/boundaries-ui.md');
@@ -22,22 +19,36 @@ class CodexSkillsContractTest extends TestCase
$this->readProjectFile('tools/codex-skills/core-guardrails/references/source-map.md');
}
public function testStarterkitPlannerSkillContainsDecisionCompleteTemplateContract(): void
public function testStarterkitPlannerSkillAndReferencesExist(): void
{
$skill = $this->readProjectFile('tools/codex-skills/starterkit-planner/SKILL.md');
$this->assertStringContainsString('name: starterkit-planner', $skill);
$this->assertStringContainsString('Decision-complete Implementierungsplan', $skill);
$this->readProjectFile('tools/codex-skills/starterkit-planner/references/plan-template.md');
$this->readProjectFile('tools/codex-skills/starterkit-planner/references/tradeoff-matrix.md');
}
public function testCodexPromptsDocumentationReferencesBothSkillsAndExtensionScope(): void
public function testStarterkitGridStandardsSkillAndReferencesExist(): void
{
$skill = $this->readProjectFile('tools/codex-skills/starterkit-grid-standards/SKILL.md');
$this->assertStringContainsString('name: starterkit-grid-standards', $skill);
$this->readProjectFile('tools/codex-skills/starterkit-grid-standards/references/list-checklist.md');
$this->readProjectFile('tools/codex-skills/starterkit-grid-standards/references/row-interaction-standard.md');
$this->readProjectFile('tools/codex-skills/starterkit-grid-standards/references/page-size-standard.md');
}
public function testStarterkitPhpStyleCiSkillExists(): void
{
$skill = $this->readProjectFile('tools/codex-skills/starterkit-php-style-ci/SKILL.md');
$this->assertStringContainsString('name: starterkit-php-style-ci', $skill);
}
public function testCodexPromptsDocumentationReferencesBothSkills(): void
{
$prompts = $this->readProjectFile('docs/codex-prompts.md');
$this->assertStringContainsString('core-guardrails', $prompts);
$this->assertStringContainsString('starterkit-planner', $prompts);
$this->assertStringContainsString('Out of Scope: Extensions', $prompts);
}
public function testDocsIndexReferencesCodexPrompts(): void
@@ -52,7 +63,5 @@ class CodexSkillsContractTest extends TestCase
$this->assertStringContainsString('--check', $script);
$this->assertStringContainsString('--apply', $script);
$this->assertStringContainsString('mode="check"', $script);
$this->assertStringContainsString('core-guardrails', $script);
$this->assertStringContainsString('starterkit-planner', $script);
}
}

View File

@@ -41,6 +41,9 @@ class CoreStarterkitContractTest extends TestCase
'/\$_GET/' => '$_GET',
'/\$_FILES/' => '$_FILES',
'/REQUEST_METHOD/' => 'REQUEST_METHOD',
'/\$_SESSION/' => '$_SESSION',
'/\$_SERVER/' => '$_SERVER',
'/\$_COOKIE/' => '$_COOKIE',
];
foreach ($patterns as $pattern => $label) {
@@ -49,29 +52,30 @@ class CoreStarterkitContractTest extends TestCase
}
}
public function testServicesDoNotUseSuperglobals(): void
{
$patterns = [
'/\$_POST/' => '$_POST',
'/\$_GET/' => '$_GET',
'/\$_FILES/' => '$_FILES',
'/\$_SERVER/' => '$_SERVER',
'/\$_COOKIE/' => '$_COOKIE',
'/\$_SESSION/' => '$_SESSION',
'/\$_REQUEST/' => '$_REQUEST',
];
foreach ($patterns as $pattern => $label) {
$violations = $this->findPatternMatchesInPhpFiles('lib/Service', $pattern);
$this->assertSame([], $violations, "Superglobal {$label} usage found in lib/Service/:\n" . implode("\n", $violations));
}
}
public function testApiPagesDoNotReadJsonBodyDirectly(): void
{
$violations = $this->findPatternMatchesInPhpFiles('pages/api/v1', '/ApiResponse::readJsonBody\s*\(/');
$this->assertSame([], $violations, "ApiResponse::readJsonBody usage found in pages/api/v1/:\n" . implode("\n", $violations));
}
public function testLegacyAndTemplateModulesAreRemoved(): void
{
$root = $this->projectRootPath();
$this->assertFileDoesNotExist(
$root . '/web/js/components/app-filter-overflow.js',
'Legacy filter overflow module must remain removed.'
);
$this->assertFileDoesNotExist(
$root . '/web/js/components/app-toggle-toolbar.js',
'Legacy toolbar toggle module must remain removed.'
);
$this->assertFileDoesNotExist(
$root . '/web/js/components/_template.js',
'Component template module must remain removed from production JS path.'
);
}
public function testTemplatesDoNotUseLegacyFilterToggleMarkup(): void
{
$pattern = '/data-(?:toolbar-toggle|filter-overflow|filter-toggle)/';
@@ -108,6 +112,34 @@ class CoreStarterkitContractTest extends TestCase
$this->assertSame([], $violations, "asset(...) JS URLs found in templates/pages:\n" . implode("\n", $violations));
}
public function testNoLegacySettingsGatewayOrServiceReferencesExist(): void
{
$patterns = [
'/\bSettingService::class\b/' => 'SettingService::class',
'/\bSettingGateway::class\b/' => 'SettingGateway::class',
'/\bnew\s+SettingService\s*\(/' => 'new SettingService(...)',
'/\bnew\s+SettingGateway\s*\(/' => 'new SettingGateway(...)',
'/use\s+[^;]*\\\\SettingService;/' => 'use ...\\SettingService',
'/use\s+[^;]*\\\\SettingGateway;/' => 'use ...\\SettingGateway',
];
foreach ($patterns as $pattern => $label) {
$violations = array_merge(
$this->findPatternMatchesInPhpFiles('lib', $pattern),
$this->findPatternMatchesInPhpFiles('pages', $pattern),
$this->findPatternMatchesInPhpFiles('tests', $pattern),
);
$violations = array_values(array_filter(
$violations,
static fn (string $path): bool => $path !== 'tests/Architecture/CoreStarterkitContractTest.php'
));
$violations = array_values(array_unique($violations));
sort($violations);
$this->assertSame([], $violations, "Legacy settings reference {$label} found:\n" . implode("\n", $violations));
}
}
/**
* @return list<string>
*/

View File

@@ -0,0 +1,173 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class FrontendTelemetryContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testEventTypeMappingsStayInSyncAcrossFrontendAndBackend(): void
{
$js = $this->readProjectFile('web/js/core/app-telemetry.js');
$ingest = $this->readProjectFile('lib/Service/Audit/FrontendTelemetryIngestService.php');
$settings = $this->readProjectFile('lib/Service/Settings/SettingsFrontendTelemetryGateway.php');
$jsMap = $this->parseJsEventKeyToTypeMap($js);
$phpMap = $this->parsePhpEventTypeToKeyMap($ingest);
$expectedPhpMap = array_flip($jsMap);
ksort($expectedPhpMap);
ksort($phpMap);
$this->assertSame($expectedPhpMap, $phpMap);
$settingsAllowedEvents = $this->parsePhpStringArrayConstant(
$settings,
'FRONTEND_TELEMETRY_ALLOWED_EVENTS'
);
sort($settingsAllowedEvents, SORT_STRING);
$expectedAllowedEvents = array_keys($jsMap);
sort($expectedAllowedEvents, SORT_STRING);
$this->assertSame($expectedAllowedEvents, $settingsAllowedEvents);
}
public function testAllowedMetaKeysStayInSyncAcrossFrontendAndBackend(): void
{
$js = $this->readProjectFile('web/js/core/app-telemetry.js');
$ingest = $this->readProjectFile('lib/Service/Audit/FrontendTelemetryIngestService.php');
$jsKeys = $this->parseJsStringSet($js, 'ALLOWED_META_KEYS');
$phpKeys = $this->parsePhpStringArrayConstant($ingest, 'ALLOWED_META_KEYS');
sort($jsKeys, SORT_STRING);
sort($phpKeys, SORT_STRING);
$this->assertSame($jsKeys, $phpKeys);
}
public function testLengthLimitsStayInSyncAcrossFrontendAndBackend(): void
{
$js = $this->readProjectFile('web/js/core/app-telemetry.js');
$ingest = $this->readProjectFile('lib/Service/Audit/FrontendTelemetryIngestService.php');
$this->assertSame(
$this->parseJsIntConstant($js, 'MAX_MESSAGE_LENGTH'),
$this->parsePhpIntConstant($ingest, 'MESSAGE_MAX_LENGTH')
);
$this->assertSame(
$this->parseJsIntConstant($js, 'MAX_FINGERPRINT_LENGTH'),
$this->parsePhpIntConstant($ingest, 'FINGERPRINT_MAX_LENGTH')
);
$this->assertSame(
$this->parseJsIntConstant($js, 'MAX_META_ENTRIES'),
$this->parsePhpIntConstant($ingest, 'META_MAX_ENTRIES')
);
$this->assertSame(
$this->parseJsIntConstant($js, 'MAX_META_VALUE_LENGTH'),
$this->parsePhpIntConstant($ingest, 'META_VALUE_MAX_LENGTH')
);
}
/**
* @return array<string,string>
*/
private function parseJsEventKeyToTypeMap(string $content): array
{
if (preg_match('/const\s+EVENT_KEY_TO_TYPE\s*=\s*Object\.freeze\(\{(.*?)\}\);/s', $content, $match) !== 1) {
$this->fail('Could not parse EVENT_KEY_TO_TYPE from JS telemetry module.');
}
$map = [];
preg_match_all('/([a-z_]+)\s*:\s*\'([^\']+)\'/', $match[1], $pairs, PREG_SET_ORDER);
foreach ($pairs as $pair) {
$map[$pair[1]] = $pair[2];
}
$this->assertNotSame([], $map, 'EVENT_KEY_TO_TYPE map is empty.');
return $map;
}
/**
* @return array<string,string>
*/
private function parsePhpEventTypeToKeyMap(string $content): array
{
$constValues = [];
preg_match_all('/private const\s+([A-Z0-9_]+)\s*=\s*\'([^\']+)\';/', $content, $constants, PREG_SET_ORDER);
foreach ($constants as $constant) {
$constValues[$constant[1]] = $constant[2];
}
if (preg_match('/private const EVENT_KEY_MAP = \[(.*?)\];/s', $content, $match) !== 1) {
$this->fail('Could not parse EVENT_KEY_MAP from ingest service.');
}
$map = [];
preg_match_all('/self::([A-Z0-9_]+)\s*=>\s*\'([a-z_]+)\'/', $match[1], $pairs, PREG_SET_ORDER);
foreach ($pairs as $pair) {
$eventConstant = $pair[1];
$eventType = $constValues[$eventConstant] ?? '';
$this->assertNotSame('', $eventType, 'Missing event constant for EVENT_KEY_MAP entry: ' . $eventConstant);
$map[$eventType] = $pair[2];
}
$this->assertNotSame([], $map, 'EVENT_KEY_MAP is empty.');
return $map;
}
/**
* @return list<string>
*/
private function parseJsStringSet(string $content, string $constantName): array
{
$pattern = '/const\s+' . preg_quote($constantName, '/') . '\s*=\s*new Set\(\[(.*?)\]\);/s';
if (preg_match($pattern, $content, $match) !== 1) {
$this->fail('Could not parse JS Set constant: ' . $constantName);
}
$values = [];
preg_match_all('/\'([^\']+)\'/', $match[1], $items, PREG_SET_ORDER);
foreach ($items as $item) {
$values[] = $item[1];
}
return array_values(array_unique($values));
}
/**
* @return list<string>
*/
private function parsePhpStringArrayConstant(string $content, string $constantName): array
{
$pattern = '/private const\s+' . preg_quote($constantName, '/') . '\s*=\s*\[(.*?)\];/s';
if (preg_match($pattern, $content, $match) !== 1) {
$this->fail('Could not parse PHP array constant: ' . $constantName);
}
$values = [];
preg_match_all('/\'([^\']+)\'/', $match[1], $items, PREG_SET_ORDER);
foreach ($items as $item) {
$values[] = $item[1];
}
return array_values(array_unique($values));
}
private function parseJsIntConstant(string $content, string $constantName): int
{
$pattern = '/const\s+' . preg_quote($constantName, '/') . '\s*=\s*([0-9]+)\s*;/';
if (preg_match($pattern, $content, $match) !== 1) {
$this->fail('Could not parse JS integer constant: ' . $constantName);
}
return (int) $match[1];
}
private function parsePhpIntConstant(string $content, string $constantName): int
{
$pattern = '/private const\s+' . preg_quote($constantName, '/') . '\s*=\s*([0-9]+)\s*;/';
if (preg_match($pattern, $content, $match) !== 1) {
$this->fail('Could not parse PHP integer constant: ' . $constantName);
}
return (int) $match[1];
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Http\RequestRuntime;
use MintyPHP\Http\RequestContext;
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
use MintyPHP\Service\Audit\ApiAuditService;
@@ -28,7 +29,10 @@ class ApiAuditServiceTest extends TestCase
public function testCurrentRequestIdIsNullBeforeStart(): void
{
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$this->assertNull($service->currentRequestId());
}
@@ -38,7 +42,10 @@ class ApiAuditServiceTest extends TestCase
$_SERVER['REQUEST_URI'] = '/api/v1/me?x=1';
$_GET = ['x' => '1'];
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$requestId = $service->currentRequestId();
@@ -58,7 +65,10 @@ class ApiAuditServiceTest extends TestCase
$_SERVER['REQUEST_URI'] = '/api/v1/me';
$_GET = [];
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$this->assertNull($service->currentRequestId());

View File

@@ -0,0 +1,332 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Audit\FrontendTelemetryIngestService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use PHPUnit\Framework\TestCase;
class FrontendTelemetryIngestServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testIngestSkipsWhenDisabled(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(false);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'message' => 'warn',
]);
$this->assertSame(['accepted' => false, 'reason' => 'disabled'], $result);
}
public function testIngestRejectsInvalidPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'unknown',
'message' => '',
]);
$this->assertSame(['accepted' => false, 'reason' => 'invalid'], $result);
}
public function testIngestRecordsSanitizedPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(function (array $context): bool {
$metadata = $context['metadata'] ?? [];
$message = (string) ($metadata['message'] ?? '');
if (!str_contains($message, '[REDACTED_EMAIL]')) {
return false;
}
if (!str_contains($message, 'token=[REDACTED]')) {
return false;
}
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
$requestPath = (string) ($meta['request_path'] ?? '');
return $requestPath === '/admin/users/:id';
})
)
->willReturn(5);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Problem for user test@example.com token=supersecret1234567890',
'fingerprint' => 'v1_warn_once_123',
'meta' => ['request_path' => '/admin/users/123?email=a@b.de'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestBuildsFallbackFingerprintUsingRouteBeforeMessage(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$fingerprintHash = (string) ($metadata['fingerprint_hash'] ?? '');
$expectedFingerprint = 'v1_' . substr(hash('sha256', 'frontend.warn_once|/admin/users/:id|Grid init failed'), 0, 24);
return $fingerprintHash === RequestContext::hashValue($expectedFingerprint);
})
)
->willReturn(6);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Grid init failed',
'meta' => ['location' => '/admin/users/123'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestRejectsInvalidPageRequestIdPattern(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
return !array_key_exists('page_request_id', $meta);
})
)
->willReturn(8);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Invalid request id should be dropped',
'fingerprint' => 'v1_invalid_request_id',
'meta' => ['page_request_id' => '------------------------------------'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestUsesSingleSessionStateReadWriteForGuards(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->willReturn(7);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->expects($this->once())
->method('hit')
->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]);
$sessionStore->expects($this->once())
->method('get')
->with('frontend_telemetry_state', [])
->willReturn([]);
$sessionStore->expects($this->once())
->method('set')
->with(
'frontend_telemetry_state',
$this->callback(static function (mixed $value): bool {
if (!is_array($value)) {
return false;
}
if (!is_array($value['dedupe'] ?? null)) {
return false;
}
if (!is_array($value['burst'] ?? null)) {
return false;
}
return true;
})
);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'single state write',
'fingerprint' => 'v1_single_state_write',
'meta' => ['location' => '/admin/test'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestDropsDuplicateFingerprint(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record')->willReturn(9);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$first = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$second = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $first);
$this->assertSame(['accepted' => false, 'reason' => 'duplicate'], $second);
}
public function testIngestAppliesSessionBurstFastDropBeforeGlobalRateLimit(): void
{
$recordCalls = 0;
$rateLimiterCalls = 0;
$audit = $this->createMock(SystemAuditService::class);
$audit->method('record')->willReturnCallback(static function () use (&$recordCalls): int {
$recordCalls++;
return $recordCalls;
});
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturnCallback(static function () use (&$rateLimiterCalls): array {
$rateLimiterCalls++;
return ['allowed' => true, 'retry_after' => 0];
});
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$lastResult = ['accepted' => true, 'reason' => 'logged'];
for ($index = 0; $index < 120; $index++) {
$lastResult = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_' . $index,
]);
}
$this->assertSame(['accepted' => false, 'reason' => 'burst_limited'], $lastResult);
$this->assertGreaterThan(0, $recordCalls);
$this->assertLessThan(120, $recordCalls);
$this->assertSame($recordCalls, $rateLimiterCalls);
}
}

View File

@@ -2,11 +2,12 @@
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Http\SessionStore;
use MintyPHP\Http\RequestContext;
use MintyPHP\Repository\Audit\SystemAuditLogRepository;
use MintyPHP\Service\Audit\SystemAuditRedactionService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
use PHPUnit\Framework\TestCase;
class SystemAuditServiceTest extends TestCase
@@ -14,6 +15,7 @@ class SystemAuditServiceTest extends TestCase
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testRecordReturnsNullWhenDisabled(): void
@@ -21,10 +23,10 @@ class SystemAuditServiceTest extends TestCase
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->never())->method('create');
$settings = $this->createMock(SettingGateway::class);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->expects($this->once())->method('isSystemAuditEnabled')->willReturn(false);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
@@ -39,10 +41,10 @@ class SystemAuditServiceTest extends TestCase
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('create')->willReturn(5);
$settings = $this->createMock(SettingGateway::class);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$id = $service->record('admin.users.update', 'success', [
'target_type' => 'user',
@@ -59,10 +61,10 @@ class SystemAuditServiceTest extends TestCase
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db down'));
$settings = $this->createMock(SettingGateway::class);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
@@ -72,10 +74,10 @@ class SystemAuditServiceTest extends TestCase
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('purgeOlderThanDays')->with(120)->willReturn(3);
$settings = $this->createMock(SettingGateway::class);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('getSystemAuditRetentionDays')->willReturn(120);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertSame(3, $service->purgeExpired());
}

View File

@@ -0,0 +1,76 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Http\SessionStore;
use MintyPHP\Service\Auth\AuthSavedFilterGateway;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\User\UserTenantContextService;
use PHPUnit\Framework\TestCase;
class AuthSessionTenantContextServiceTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testHydrateMarksNoActiveTenantWhenUserHasNoTenants(): void
{
$_SESSION['current_tenant'] = ['id' => 9];
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn([]);
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
$userTenantContextService->expects($this->never())->method('getCurrentTenant');
$savedFilterGateway = $this->createMock(AuthSavedFilterGateway::class);
$savedFilterGateway->method('listAddressBookFilters')->willReturn([]);
$service = new AuthSessionTenantContextService(
$userTenantContextService,
$savedFilterGateway,
new SessionStore()
);
$service->hydrateForUser(5);
$this->assertSame([], $_SESSION['available_tenants']);
$this->assertSame([], $_SESSION['available_departments_by_tenant']);
$this->assertSame([], $_SESSION['address_book_saved_filters']);
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
}
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
{
$availableTenants = [
['id' => 7, 'uuid' => 'tenant-7'],
['id' => 8, 'uuid' => 'tenant-8'],
];
$availableDepartments = ['7' => [['id' => 11]]];
$savedFilters = [['name' => 'Favorites']];
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn($availableDepartments);
$userTenantContextService->method('getCurrentTenant')->willReturn(null);
$savedFilterGateway = $this->createMock(AuthSavedFilterGateway::class);
$savedFilterGateway->method('listAddressBookFilters')->willReturn($savedFilters);
$service = new AuthSessionTenantContextService(
$userTenantContextService,
$savedFilterGateway,
new SessionStore()
);
$service->hydrateForUser(5);
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
$this->assertSame($availableDepartments, $_SESSION['available_departments_by_tenant']);
$this->assertSame($savedFilters, $_SESSION['address_book_saved_filters']);
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
}
}

View File

@@ -0,0 +1,567 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Service\Auth\AuthTenantGateway;
use MintyPHP\Service\Auth\MicrosoftOidcService;
use MintyPHP\Service\Auth\MicrosoftOidcStateStoreService;
use MintyPHP\Service\Auth\OidcHttpGateway;
use MintyPHP\Service\Auth\TenantSsoService;
use PHPUnit\Framework\TestCase;
class MicrosoftOidcServiceTest extends TestCase
{
public function testStartAuthorizationReturnsTenantNotFoundForInvalidTenant(): void
{
$service = $this->newService();
$result = $service->startAuthorization(['id' => 0], ['authority' => 'https://login.microsoftonline.com/common/v2.0']);
$this->assertFalse($result['ok']);
$this->assertSame('tenant_not_found', $result['error']);
}
public function testStartAuthorizationReturnsDiscoveryFailedWhenMetadataLookupFails(): void
{
$oidcGateway = $this->createMock(OidcHttpGateway::class);
$oidcGateway->expects($this->once())
->method('call')
->willReturn(['status' => 500, 'data' => '']);
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->expects($this->never())->method('prune');
$stateStore->expects($this->never())->method('store');
$service = $this->newService(null, null, $oidcGateway, $stateStore);
$result = $service->startAuthorization(
['id' => 7],
['authority' => 'https://login.microsoftonline.com/common/v2.0']
);
$this->assertFalse($result['ok']);
$this->assertSame('oidc_discovery_failed', $result['error']);
}
public function testStartAuthorizationReturnsAuthorizeEndpointMissingWhenMetadataIsIncomplete(): void
{
$oidcGateway = $this->createMock(OidcHttpGateway::class);
$oidcGateway->method('call')->willReturn([
'status' => 200,
'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
]);
$storedState = '';
$storedPayload = [];
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->expects($this->once())->method('prune');
$stateStore->expects($this->once())
->method('store')
->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
$storedState = $state;
$storedPayload = $payload;
});
$service = $this->newService(null, null, $oidcGateway, $stateStore);
$result = $service->startAuthorization(
['id' => 7],
['authority' => 'https://login.microsoftonline.com/common/v2.0', 'client_id' => 'client-id']
);
$this->assertFalse($result['ok']);
$this->assertSame('authorize_endpoint_missing', $result['error']);
$this->assertNotSame('', $storedState);
$this->assertSame(7, (int) ($storedPayload['tenant_id'] ?? 0));
}
public function testStartAuthorizationBuildsPkceAuthorizationUrlAndStoresState(): void
{
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn(['avatar']);
$tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(true);
$oidcGateway = $this->createMock(OidcHttpGateway::class);
$oidcGateway->expects($this->once())
->method('call')
->willReturn([
'status' => 200,
'data' => json_encode([
'authorization_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
]) ?: '{}',
]);
$storedState = '';
$storedPayload = [];
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->expects($this->once())->method('prune');
$stateStore->expects($this->once())
->method('store')
->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
$storedState = $state;
$storedPayload = $payload;
});
$service = $this->newService($tenantSsoService, null, $oidcGateway, $stateStore);
$result = $service->startAuthorization(
['id' => 9],
[
'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
'client_id' => 'client-id',
'sync_profile_on_login' => 1,
'sync_profile_fields' => ['avatar'],
]
);
$this->assertTrue($result['ok']);
$this->assertNotSame('', $storedState);
$this->assertSame(9, (int) ($storedPayload['tenant_id'] ?? 0));
$this->assertNotSame('', (string) ($storedPayload['redirect_uri'] ?? ''));
$query = $this->queryFromUrl((string) ($result['url'] ?? ''));
$this->assertSame('client-id', (string) ($query['client_id'] ?? ''));
$this->assertSame('code', (string) ($query['response_type'] ?? ''));
$this->assertSame('query', (string) ($query['response_mode'] ?? ''));
$this->assertSame('S256', (string) ($query['code_challenge_method'] ?? ''));
$this->assertSame($storedState, (string) ($query['state'] ?? ''));
$this->assertSame((string) ($storedPayload['nonce'] ?? ''), (string) ($query['nonce'] ?? ''));
$this->assertSame((string) ($storedPayload['redirect_uri'] ?? ''), (string) ($query['redirect_uri'] ?? ''));
$this->assertStringContainsString('openid', (string) ($query['scope'] ?? ''));
$this->assertStringContainsString('User.Read', (string) ($query['scope'] ?? ''));
}
public function testHandleCallbackReturnsCallbackInvalidWhenInputIsEmpty(): void
{
$service = $this->newService();
$result = $service->handleCallback(' ', '');
$this->assertFalse($result['ok']);
$this->assertSame('callback_invalid', $result['error']);
}
public function testHandleCallbackReturnsStateErrorWhenStateCannotBeConsumed(): void
{
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->expects($this->once())
->method('consume')
->with('state-x')
->willReturn(['ok' => false, 'error' => 'state_invalid']);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->expects($this->never())->method('findById');
$service = $this->newService(null, $tenantGateway, null, $stateStore);
$result = $service->handleCallback('state-x', 'code-x');
$this->assertFalse($result['ok']);
$this->assertSame('state_invalid', $result['error']);
}
public function testHandleCallbackReturnsTenantNotFoundForInactiveTenant(): void
{
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->method('consume')->willReturn([
'ok' => true,
'entry' => [
'tenant_id' => 5,
'nonce' => 'nonce',
'code_verifier' => 'verifier',
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
],
]);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->expects($this->once())
->method('findById')
->with(5)
->willReturn(['id' => 5, 'status' => 'inactive']);
$service = $this->newService(null, $tenantGateway, null, $stateStore);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('tenant_not_found', $result['error']);
}
public function testHandleCallbackReturnsConfigErrorFromTenantSsoService(): void
{
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->method('consume')->willReturn([
'ok' => true,
'entry' => [
'tenant_id' => 8,
'nonce' => 'nonce',
'code_verifier' => 'verifier',
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
],
]);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->expects($this->once())
->method('findById')
->with(8)
->willReturn(['id' => 8, 'status' => 'active']);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
'ok' => false,
'error' => 'sso_disabled',
]);
$service = $this->newService($tenantSsoService, $tenantGateway, null, $stateStore);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('sso_disabled', $result['error']);
}
public function testHandleCallbackReturnsTokenEndpointMissingWhenDiscoveryMetadataIsIncomplete(): void
{
$service = $this->newServiceForTokenFlow([
'status' => 200,
'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
]);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('token_endpoint_missing', $result['error']);
}
public function testHandleCallbackReturnsTokenExchangeFailedWhenTokenCallIsNotSuccessful(): void
{
$service = $this->newServiceForTokenFlow(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 401,
'data' => json_encode(['error' => 'invalid_grant']) ?: '{}',
]
);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('token_exchange_failed', $result['error']);
}
public function testHandleCallbackReturnsTokenResponseInvalidWhenBodyIsNotJson(): void
{
$service = $this->newServiceForTokenFlow(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 200,
'data' => 'not-json',
]
);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('token_response_invalid', $result['error']);
}
public function testHandleCallbackReturnsIdTokenMissingWhenTokenPayloadHasNoIdToken(): void
{
$service = $this->newServiceForTokenFlow(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode(['access_token' => 'access']) ?: '{}',
]
);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('id_token_missing', $result['error']);
}
public function testHandleCallbackReturnsValidationErrorWhenIdTokenIsMalformed(): void
{
$service = $this->newServiceForTokenFlow(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode(['id_token' => 'not-a-jwt']) ?: '{}',
]
);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertFalse($result['ok']);
$this->assertSame('id_token_format_invalid', $result['error']);
}
public function testHandleCallbackReturnsClaimsForValidToken(): void
{
[$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->method('consume')->willReturn([
'ok' => true,
'entry' => [
'tenant_id' => 12,
'nonce' => 'nonce-123',
'code_verifier' => 'verifier-123',
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
'locale' => 'de',
],
]);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->expects($this->once())
->method('findById')
->with(12)
->willReturn([
'id' => 12,
'status' => 'active',
'uuid' => 'tenant-uuid',
]);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
'ok' => true,
'config' => [
'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'tenant_id' => 'tenant-id-123',
'allowed_domains' => 'example.com',
'sync_profile_on_login' => 0,
'sync_profile_fields' => [],
],
]);
$tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
$oidcGateway = $this->createMock(OidcHttpGateway::class);
$oidcGateway->expects($this->exactly(3))
->method('call')
->willReturnOnConsecutiveCalls(
[
'status' => 200,
'data' => json_encode([
'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode([
'id_token' => $idToken,
'access_token' => '',
]) ?: '{}',
],
[
'status' => 200,
'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
],
);
$service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
$result = $service->handleCallback('state-ok', 'code-ok');
$this->assertTrue($result['ok']);
$this->assertSame('de', (string) ($result['locale'] ?? ''));
$this->assertSame('user@example.com', (string) (($result['claims'] ?? [])['email'] ?? ''));
$this->assertSame('oid-123', (string) (($result['claims'] ?? [])['oid'] ?? ''));
$this->assertSame('sub-123', (string) (($result['claims'] ?? [])['subject'] ?? ''));
$this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
}
private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
{
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
$stateStore->method('consume')->willReturn([
'ok' => true,
'entry' => [
'tenant_id' => 8,
'nonce' => 'nonce-1',
'code_verifier' => 'verifier-1',
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
'locale' => 'de',
],
]);
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn([
'id' => 8,
'status' => 'active',
]);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
'ok' => true,
'config' => [
'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'tenant_id' => 'tenant-id-123',
'allowed_domains' => 'example.com',
'sync_profile_on_login' => 0,
'sync_profile_fields' => [],
],
]);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
$oidcGateway = $this->createMock(OidcHttpGateway::class);
if ($tokenResponse === null) {
$oidcGateway->method('call')->willReturn($discoveryResponse);
} else {
$oidcGateway->method('call')->willReturnOnConsecutiveCalls($discoveryResponse, $tokenResponse);
}
return $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
}
private function newService(
?TenantSsoService $tenantSsoService = null,
?AuthTenantGateway $tenantGateway = null,
?OidcHttpGateway $oidcGateway = null,
?MicrosoftOidcStateStoreService $stateStore = null
): MicrosoftOidcService {
if ($tenantSsoService === null) {
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
$tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(false);
$tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);
$tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
'ok' => true,
'config' => [
'authority' => 'https://login.microsoftonline.com/common/v2.0',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'tenant_id' => 'tenant-id-123',
'allowed_domains' => 'example.com',
'sync_profile_on_login' => 0,
'sync_profile_fields' => [],
],
]);
}
$tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
$oidcGateway = $oidcGateway ?? $this->createMock(OidcHttpGateway::class);
$stateStore = $stateStore ?? $this->createMock(MicrosoftOidcStateStoreService::class);
return new MicrosoftOidcService(
$tenantSsoService,
$tenantGateway,
$oidcGateway,
$stateStore
);
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function buildValidIdToken(string $clientId, string $tenantId, string $nonce): array
{
$key = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_RSA,
'private_key_bits' => 2048,
]);
if ($key === false) {
$this->fail('Unable to generate RSA key for OIDC token test');
}
$details = openssl_pkey_get_details($key);
if (!is_array($details) || !is_array($details['rsa'] ?? null)) {
$this->fail('Unable to read RSA key details');
}
$header = ['alg' => 'RS256', 'kid' => 'kid-123', 'typ' => 'JWT'];
$claims = [
'aud' => $clientId,
'nonce' => $nonce,
'tid' => $tenantId,
'iss' => 'https://login.microsoftonline.com/' . $tenantId . '/v2.0',
'exp' => time() + 300,
'nbf' => time() - 10,
'oid' => 'oid-123',
'sub' => 'sub-123',
'email' => 'user@example.com',
'name' => 'Example User',
];
$headerJson = json_encode($header);
$claimsJson = json_encode($claims);
if (!is_string($headerJson) || !is_string($claimsJson)) {
$this->fail('Unable to encode token payload');
}
$encodedHeader = self::base64UrlEncode($headerJson);
$encodedClaims = self::base64UrlEncode($claimsJson);
$signingInput = $encodedHeader . '.' . $encodedClaims;
$signature = '';
$signed = openssl_sign($signingInput, $signature, $key, OPENSSL_ALGO_SHA256);
if (!$signed) {
$this->fail('Unable to sign token payload');
}
$rsa = $details['rsa'];
$modulus = is_string($rsa['n'] ?? null) ? $rsa['n'] : '';
$exponent = is_string($rsa['e'] ?? null) ? $rsa['e'] : '';
if ($modulus === '' || $exponent === '') {
$this->fail('Missing RSA modulus/exponent');
}
return [
$signingInput . '.' . self::base64UrlEncode($signature),
[
'kid' => 'kid-123',
'n' => self::base64UrlEncode($modulus),
'e' => self::base64UrlEncode($exponent),
],
];
}
/**
* @return array<string, string>
*/
private function queryFromUrl(string $url): array
{
$parts = parse_url($url);
if (!is_array($parts)) {
return [];
}
$query = [];
parse_str((string) ($parts['query'] ?? ''), $query);
return array_map(static fn ($value): string => (string) $value, $query);
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Http\SessionStore;
use MintyPHP\Service\Auth\MicrosoftOidcStateStoreService;
use PHPUnit\Framework\TestCase;
@@ -24,7 +25,7 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
public function testStoreAndConsumeIsSingleUse(): void
{
$store = new MicrosoftOidcStateStoreService(600, 50);
$store = new MicrosoftOidcStateStoreService(new SessionStore(), 600, 50);
$store->store('state-a', ['tenant_id' => 5, 'nonce' => 'n1']);
$first = $store->consume('state-a');
@@ -38,7 +39,7 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
public function testConsumeReturnsExpiredForOldState(): void
{
$store = new MicrosoftOidcStateStoreService(1, 50);
$store = new MicrosoftOidcStateStoreService(new SessionStore(), 1, 50);
$store->store('state-old', [
'tenant_id' => 9,
'created_at' => time() - 120,
@@ -51,7 +52,7 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
public function testStoreRespectsMaxEntriesAndPrunesOldStates(): void
{
$store = new MicrosoftOidcStateStoreService(600, 2);
$store = new MicrosoftOidcStateStoreService(new SessionStore(), 600, 2);
$store->store('state-1', ['created_at' => time() - 3]);
$store->store('state-2', ['created_at' => time() - 2]);

View File

@@ -0,0 +1,309 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
use MintyPHP\Service\Auth\AuthCryptoGateway;
use MintyPHP\Service\Auth\AuthSettingsGateway;
use MintyPHP\Service\Auth\AuthTenantGateway;
use MintyPHP\Service\Auth\TenantSsoService;
use PHPUnit\Framework\TestCase;
class TenantSsoServiceTest extends TestCase
{
public function testGetEffectiveMicrosoftProviderConfigReturnsSsoDisabledWhenTenantAuthDisabled(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn(['enabled' => 0]);
$service = $this->newService($repository);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('sso_disabled', $result['error']);
}
public function testGetEffectiveMicrosoftProviderConfigRejectsInvalidTenantId(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'entra_tenant_id' => 'invalid',
'use_shared_app' => 1,
]);
$service = $this->newService($repository);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('tenant_id_invalid', $result['error']);
}
public function testGetEffectiveMicrosoftProviderConfigRequiresSharedCredentials(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => 1,
]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('');
$service = $this->newService($repository, null, $settingsGateway);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('shared_credentials_missing', $result['error']);
}
public function testGetEffectiveMicrosoftProviderConfigRequiresOverrideClientId(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => 0,
'client_id_override' => '',
'client_secret_override_enc' => 'enc',
]);
$service = $this->newService($repository);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('client_id_override_required', $result['error']);
}
public function testGetEffectiveMicrosoftProviderConfigRejectsInvalidOverrideSecret(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => 0,
'client_id_override' => 'client-id',
'client_secret_override_enc' => 'enc',
]);
$cryptoGateway = $this->createMock(AuthCryptoGateway::class);
$cryptoGateway->method('decryptString')->willThrowException(new \RuntimeException('invalid secret'));
$service = $this->newService($repository, null, null, $cryptoGateway);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('secret_invalid', $result['error']);
}
public function testGetEffectiveMicrosoftProviderConfigReturnsConfigForOverrideCredentials(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'allowed_domains' => 'example.com',
'enforce_microsoft_login' => 1,
'sync_profile_on_login' => 1,
'sync_profile_fields' => 'first_name,avatar',
'use_shared_app' => 0,
'client_id_override' => 'client-id',
'client_secret_override_enc' => 'enc',
]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0');
$cryptoGateway = $this->createMock(AuthCryptoGateway::class);
$cryptoGateway->method('decryptString')->willReturn('secret-value');
$service = $this->newService($repository, null, $settingsGateway, $cryptoGateway);
$result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]);
$this->assertTrue($result['ok']);
$this->assertSame('client-id', $result['config']['client_id']);
$this->assertSame('secret-value', $result['config']['client_secret']);
$this->assertTrue((bool) $result['config']['sync_profile_needs_graph']);
$this->assertSame('https://login.microsoftonline.com/common/v2.0', $result['config']['authority']);
$this->assertFalse((bool) $result['config']['use_shared_app']);
}
public function testResolveTenantLoginMethodsReturnsTenantNotFoundForInactiveTenant(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'inactive']);
$service = $this->newService(null, $tenantGateway);
$result = $service->resolveTenantLoginMethods(5);
$this->assertFalse($result['local']);
$this->assertFalse($result['microsoft']);
$this->assertSame('tenant_not_found', $result['microsoft_reason']);
}
public function testResolveTenantLoginMethodsReturnsLocalOnlyWhenSsoDisabled(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn(['enabled' => 0]);
$service = $this->newService($repository, $tenantGateway);
$result = $service->resolveTenantLoginMethods(5);
$this->assertTrue($result['local']);
$this->assertFalse($result['microsoft']);
$this->assertSame('sso_disabled', $result['microsoft_reason']);
}
public function testResolveTenantLoginMethodsReturnsMicrosoftOnlyWhenEnforcedAndConfigured(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 1,
'enforce_microsoft_login' => 1,
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => 1,
]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('shared-client');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('shared-secret');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0');
$service = $this->newService($repository, $tenantGateway, $settingsGateway);
$result = $service->resolveTenantLoginMethods(5);
$this->assertFalse($result['local']);
$this->assertTrue($result['microsoft']);
$this->assertSame('', $result['microsoft_reason']);
}
public function testSaveTenantMicrosoftAuthReturnsErrorWhenTenantDoesNotExist(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(null);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->expects($this->never())->method('upsertByTenantId');
$service = $this->newService($repository, $tenantGateway);
$result = $service->saveTenantMicrosoftAuth(5, ['microsoft_enabled' => '1']);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testSaveTenantMicrosoftAuthValidatesCryptoWhenOverrideSecretIsProvided(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 0,
'use_shared_app' => 0,
'client_secret_override_enc' => '',
]);
$repository->expects($this->never())->method('upsertByTenantId');
$cryptoGateway = $this->createMock(AuthCryptoGateway::class);
$cryptoGateway->method('isConfigured')->willReturn(false);
$service = $this->newService($repository, $tenantGateway, null, $cryptoGateway);
$result = $service->saveTenantMicrosoftAuth(5, [
'microsoft_enabled' => '1',
'use_shared_app' => '',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'client_id_override' => 'client-id',
'client_secret_override' => 'new-secret',
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testSaveTenantMicrosoftAuthPersistsNormalizedSharedAppPayload(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'enabled' => 0,
'use_shared_app' => 1,
'client_secret_override_enc' => '',
]);
$repository->expects($this->once())
->method('upsertByTenantId')
->with(
5,
$this->callback(static function (array $data): bool {
return $data['enabled'] === true
&& $data['enforce_microsoft_login'] === true
&& $data['sync_profile_on_login'] === true
&& $data['sync_profile_fields'] === 'first_name,last_name'
&& $data['entra_tenant_id'] === '11111111-1111-1111-1111-111111111111'
&& $data['allowed_domains'] === 'example.com,test.org'
&& $data['use_shared_app'] === true
&& $data['client_id_override'] === null
&& $data['client_secret_override_enc'] === null;
})
)
->willReturn(true);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('shared-client');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('shared-secret');
$service = $this->newService($repository, $tenantGateway, $settingsGateway);
$result = $service->saveTenantMicrosoftAuth(5, [
'microsoft_enabled' => '1',
'enforce_microsoft_login' => '1',
'sync_profile_on_login' => '1',
'sync_profile_fields' => [],
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'allowed_domains' => "example.com, invalid, test.org\nexample.com",
'use_shared_app' => '1',
'client_id_override' => 'ignored',
'client_secret_override' => '',
]);
$this->assertTrue($result['ok']);
}
private function newService(
?TenantMicrosoftAuthRepositoryInterface $repository = null,
?AuthTenantGateway $tenantGateway = null,
?AuthSettingsGateway $settingsGateway = null,
?AuthCryptoGateway $cryptoGateway = null
): TenantSsoService {
$repository = $repository ?? $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
if ($settingsGateway === null) {
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('');
}
if ($cryptoGateway === null) {
$cryptoGateway = $this->createMock(AuthCryptoGateway::class);
$cryptoGateway->method('isConfigured')->willReturn(true);
$cryptoGateway->method('decryptString')->willReturn('decrypted-secret');
$cryptoGateway->method('encryptString')->willReturn('encrypted-secret');
}
return new TenantSsoService(
$tenantGateway,
$repository,
$settingsGateway,
$cryptoGateway
);
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Service\Import;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Import\CsvReaderService;
@@ -9,7 +10,7 @@ use MintyPHP\Service\Import\ImportService;
use MintyPHP\Service\Import\ImportStateStoreService;
use MintyPHP\Service\Import\ImportTempFileService;
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
use MintyPHP\Service\User\UserScopeGateway;
use PHPUnit\Framework\TestCase;
@@ -120,10 +121,12 @@ class ImportServiceTest extends TestCase
$stateStore = $stateStore ?? $this->createMock(ImportStateStoreService::class);
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')->willReturn(false);
$settingGateway = $this->createMock(SettingGateway::class);
$settingsDefaultsGateway = $this->createMock(SettingsDefaultsGateway::class);
$userScopeGateway = $this->createMock(UserScopeGateway::class);
$userScopeGateway->method('hasGlobalAccess')->willReturn(false);
$userScopeGateway->method('getUserTenantIds')->willReturn([]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('get')->willReturn([]);
return new ImportService(
$csvReader,
@@ -132,8 +135,9 @@ class ImportServiceTest extends TestCase
$stateStore,
[$profile->key() => $profile],
$permissionGateway,
$settingGateway,
$userScopeGateway
$settingsDefaultsGateway,
$userScopeGateway,
$sessionStore
);
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Service\Import;
use MintyPHP\Http\SessionStore;
use MintyPHP\Service\Import\ImportStateStoreService;
use MintyPHP\Service\Import\ImportTempFileService;
use PHPUnit\Framework\TestCase;
@@ -16,7 +17,7 @@ class ImportStateStoreServiceTest extends TestCase
public function testSetGetAndClearState(): void
{
$tempFileService = $this->createMock(ImportTempFileService::class);
$stateStore = new ImportStateStoreService($tempFileService);
$stateStore = new ImportStateStoreService($tempFileService, new SessionStore());
$stateStore->setState('token_a', ['token' => 'token_a', 'created_at' => time(), 'path' => '/tmp/a.csv']);
$loaded = $stateStore->getState('token_a');
@@ -34,7 +35,7 @@ class ImportStateStoreServiceTest extends TestCase
->method('delete')
->with('/tmp/expired.csv');
$stateStore = new ImportStateStoreService($tempFileService);
$stateStore = new ImportStateStoreService($tempFileService, new SessionStore());
$stateStore->setState('token_b', [
'token' => 'token_b',
'created_at' => time() - 7201,

View File

@@ -1,144 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Settings\SettingRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Settings\SettingService;
use MintyPHP\Service\Settings\ThemeConfigService;
use PHPUnit\Framework\TestCase;
class SettingServiceTest extends TestCase
{
public function testSetDefaultTenantIdRejectsUnknownTenant(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->expects($this->never())->method('set');
$tenantRepository = $this->createMock(TenantRepository::class);
$tenantRepository->expects($this->once())->method('find')->with(99)->willReturn(null);
$service = $this->newService($settingRepository, $tenantRepository);
$this->assertFalse($service->setDefaultTenantId(99));
}
public function testSetApiTokenDefaultTtlDaysRejectsAboveCurrentMax(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->method('getValue')->willReturnCallback(
static function (string $key): ?string {
if ($key === SettingService::API_TOKEN_MAX_TTL_DAYS_KEY) {
return '30';
}
return null;
}
);
$settingRepository->expects($this->never())->method('set');
$service = $this->newService($settingRepository);
$this->assertFalse($service->setApiTokenDefaultTtlDays(31));
}
public function testSetApiTokenMaxTtlDaysUpdatesDefaultWhenNeeded(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->method('getValue')->willReturnCallback(
static function (string $key): ?string {
if ($key === SettingService::API_TOKEN_DEFAULT_TTL_DAYS_KEY) {
return '365';
}
return null;
}
);
$calls = [];
$settingRepository->expects($this->exactly(2))
->method('set')
->willReturnCallback(
static function (string $key, ?string $value, ?string $description) use (&$calls): bool {
$calls[] = [$key, $value, $description];
return true;
}
);
$service = $this->newService($settingRepository);
$this->assertTrue($service->setApiTokenMaxTtlDays(120));
$this->assertSame(
[
[SettingService::API_TOKEN_DEFAULT_TTL_DAYS_KEY, '120', 'setting.api_token_default_ttl_days'],
[SettingService::API_TOKEN_MAX_TTL_DAYS_KEY, '120', 'setting.api_token_max_ttl_days'],
],
$calls
);
}
public function testSetUserInactivityDeleteDaysRequiresDeactivatePolicy(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->method('getValue')->willReturnCallback(
static function (string $key): ?string {
if ($key === SettingService::USER_INACTIVITY_DEACTIVATE_DAYS_KEY) {
return '0';
}
return null;
}
);
$settingRepository->expects($this->never())->method('set');
$service = $this->newService($settingRepository);
$this->assertFalse($service->setUserInactivityDeleteDays(30));
}
public function testSetApiCorsAllowedOriginsRejectsInvalidOrigins(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->expects($this->never())->method('set');
$service = $this->newService($settingRepository);
$this->assertFalse($service->setApiCorsAllowedOrigins("https://ok.example\njavascript:alert(1)"));
}
public function testSetMicrosoftAuthorityRequiresHttps(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->expects($this->never())->method('set');
$service = $this->newService($settingRepository);
$this->assertFalse($service->setMicrosoftAuthority('http://login.microsoftonline.com/common/v2.0'));
}
public function testSetAppPrimaryColorNormalizesHexWithoutHash(): void
{
$settingRepository = $this->createMock(SettingRepository::class);
$settingRepository->expects($this->once())
->method('set')
->with(SettingService::APP_PRIMARY_COLOR_KEY, '#abc', 'setting.app_primary_color')
->willReturn(true);
$service = $this->newService($settingRepository);
$this->assertTrue($service->setAppPrimaryColor('abc'));
}
private function newService(
SettingRepository $settingRepository,
?TenantRepository $tenantRepository = null,
?RoleRepository $roleRepository = null,
?DepartmentRepository $departmentRepository = null
): SettingService {
$tenantRepository = $tenantRepository ?? $this->createMock(TenantRepository::class);
$roleRepository = $roleRepository ?? $this->createMock(RoleRepository::class);
$departmentRepository = $departmentRepository ?? $this->createMock(DepartmentRepository::class);
$themeConfigService = $this->createMock(ThemeConfigService::class);
$themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']);
return new SettingService(
$settingRepository,
$tenantRepository,
$roleRepository,
$departmentRepository,
$themeConfigService
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SettingsApiPolicyGatewayTest extends TestCase
{
public function testSetApiTokenDefaultTtlDaysRejectsAboveCurrentMax(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
if ($key === 'api_token_max_ttl_days') {
return '30';
}
return null;
});
$settings->expects($this->never())->method('set');
$gateway = new SettingsApiPolicyGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setApiTokenDefaultTtlDays(31));
}
public function testSetApiTokenMaxTtlDaysUpdatesDefaultWhenNeeded(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
if ($key === 'api_token_default_ttl_days') {
return '365';
}
return null;
});
$calls = [];
$settings->expects($this->exactly(2))
->method('set')
->willReturnCallback(static function (string $key, ?string $value, ?string $description) use (&$calls): bool {
$calls[] = [$key, $value, $description];
return true;
});
$gateway = new SettingsApiPolicyGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->setApiTokenMaxTtlDays(120));
$this->assertSame(
[
['api_token_default_ttl_days', '120', 'setting.api_token_default_ttl_days'],
['api_token_max_ttl_days', '120', 'setting.api_token_max_ttl_days'],
],
$calls
);
}
public function testSetApiCorsAllowedOriginsRejectsInvalidOrigins(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$gateway = new SettingsApiPolicyGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setApiCorsAllowedOrigins("https://ok.example\njavascript:alert(1)"));
}
public function testCorsOriginsRoundtripNormalizesAndDeduplicates(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$stored = null;
$settings->expects($this->once())
->method('set')
->willReturnCallback(static function (string $key, ?string $value) use (&$stored): bool {
if ($key === 'api_cors_allowed_origins') {
$stored = $value;
}
return true;
});
$settings->method('getValue')->willReturnCallback(static function (string $key) use (&$stored): ?string {
if ($key === 'api_cors_allowed_origins') {
return $stored;
}
return null;
});
$gateway = new SettingsApiPolicyGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->setApiCorsAllowedOrigins("https://a.example,https://a.example/\nhttps://b.example:443"));
$this->assertSame(['https://a.example', 'https://b.example'], $gateway->getApiCorsAllowedOrigins());
$this->assertSame("https://a.example\nhttps://b.example", $gateway->getApiCorsAllowedOriginsText());
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsAppGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\ThemeConfigService;
use PHPUnit\Framework\TestCase;
class SettingsAppGatewayTest extends TestCase
{
public function testSetAppLocaleStoresNullForEmptyValue(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())->method('set')->with('app_locale', null, 'setting.app_locale')->willReturn(true);
$gateway = $this->newGateway($settings);
$this->assertTrue($gateway->setAppLocale(' '));
}
public function testSetAppThemeStoresNullForUnknownTheme(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())->method('set')->with('app_theme', null, 'setting.app_theme')->willReturn(true);
$gateway = $this->newGateway($settings);
$this->assertTrue($gateway->setAppTheme('unknown'));
}
public function testBooleanFlagsUseExpectedDefaults(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnMap([
['app_theme_user', null],
['app_registration', null],
]);
$gateway = $this->newGateway($settings);
$this->assertTrue($gateway->isUserThemeAllowed());
$this->assertTrue($gateway->isRegistrationEnabled());
}
public function testSetAppPrimaryColorNormalizesHexWithoutHash(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with('app_primary_color', '#abc', 'setting.app_primary_color')
->willReturn(true);
$gateway = $this->newGateway($settings);
$this->assertTrue($gateway->setAppPrimaryColor('abc'));
}
public function testSetAppPrimaryColorRejectsInvalidValue(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$gateway = $this->newGateway($settings);
$this->assertFalse($gateway->setAppPrimaryColor('not-a-color'));
}
private function newGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway
{
$themeConfigService = $this->createMock(ThemeConfigService::class);
$themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']);
return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SettingsDefaultsGatewayTest extends TestCase
{
public function testSetDefaultTenantIdRejectsUnknownTenant(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$tenantRepository = $this->createMock(TenantRepositoryInterface::class);
$tenantRepository->expects($this->once())->method('find')->with(99)->willReturn(null);
$gateway = $this->newGateway($settings, $tenantRepository);
$this->assertFalse($gateway->setDefaultTenantId(99));
}
public function testSetDefaultRoleIdRejectsUnknownRole(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$roleRepository = $this->createMock(RoleRepositoryInterface::class);
$roleRepository->expects($this->once())->method('find')->with(55)->willReturn(null);
$gateway = $this->newGateway($settings, null, $roleRepository);
$this->assertFalse($gateway->setDefaultRoleId(55));
}
public function testSetDefaultDepartmentIdRejectsUnknownDepartment(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$departmentRepository = $this->createMock(DepartmentRepositoryInterface::class);
$departmentRepository->expects($this->once())->method('find')->with(77)->willReturn(null);
$gateway = $this->newGateway($settings, null, null, $departmentRepository);
$this->assertFalse($gateway->setDefaultDepartmentId(77));
}
public function testSettersPersistValidIds(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->exactly(3))
->method('set')
->willReturn(true);
$tenantRepository = $this->createMock(TenantRepositoryInterface::class);
$tenantRepository->method('find')->willReturn(['id' => 5]);
$roleRepository = $this->createMock(RoleRepositoryInterface::class);
$roleRepository->method('find')->willReturn(['id' => 6]);
$departmentRepository = $this->createMock(DepartmentRepositoryInterface::class);
$departmentRepository->method('find')->willReturn(['id' => 7]);
$gateway = $this->newGateway($settings, $tenantRepository, $roleRepository, $departmentRepository);
$this->assertTrue($gateway->setDefaultTenantId(5));
$this->assertTrue($gateway->setDefaultRoleId(6));
$this->assertTrue($gateway->setDefaultDepartmentId(7));
}
public function testSettersAllowResetToNull(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->exactly(3))->method('set')->willReturn(true);
$gateway = $this->newGateway($settings);
$this->assertTrue($gateway->setDefaultTenantId(null));
$this->assertTrue($gateway->setDefaultRoleId(null));
$this->assertTrue($gateway->setDefaultDepartmentId(null));
}
private function newGateway(
SettingRepositoryInterface $settingRepository,
?TenantRepositoryInterface $tenantRepository = null,
?RoleRepositoryInterface $roleRepository = null,
?DepartmentRepositoryInterface $departmentRepository = null
): SettingsDefaultsGateway {
$tenantRepository = $tenantRepository ?? $this->createMock(TenantRepositoryInterface::class);
$roleRepository = $roleRepository ?? $this->createMock(RoleRepositoryInterface::class);
$departmentRepository = $departmentRepository ?? $this->createMock(DepartmentRepositoryInterface::class);
return new SettingsDefaultsGateway(
new SettingsMetadataGateway($settingRepository),
$tenantRepository,
$roleRepository,
$departmentRepository
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SettingsFrontendTelemetryGatewayTest extends TestCase
{
public function testDefaultsAreUsedWhenSettingsMissing(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn(null);
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->isFrontendTelemetryEnabled());
$this->assertSame(0.2, $gateway->getFrontendTelemetrySampleRate());
$this->assertSame(['warn_once', 'ajax_error'], $gateway->getFrontendTelemetryAllowedEvents());
}
public function testSetSampleRateRejectsOutOfRangeValues(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setFrontendTelemetrySampleRate(1.5));
}
public function testSetAllowedEventsNormalizesValues(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with(
'frontend_telemetry_allowed_events',
'ajax_error,warn_once',
'setting.frontend_telemetry_allowed_events'
)
->willReturn(true);
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->setFrontendTelemetryAllowedEvents(['frontend.warn_once', 'ajax_error', 'invalid']));
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
use PHPUnit\Framework\TestCase;
class SettingsMicrosoftGatewayTest extends TestCase
{
public function testSetMicrosoftAuthorityRequiresHttps(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
$gateway = new SettingsMicrosoftGateway(new SettingsMetadataGateway($settings), $crypto);
$this->assertFalse($gateway->setMicrosoftAuthority('http://login.microsoftonline.com/common/v2.0'));
}
public function testGetMicrosoftSharedClientSecretReturnsNullWhenDecryptFails(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('encrypted');
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
$crypto->method('decryptString')->willThrowException(new \RuntimeException('bad secret'));
$gateway = new SettingsMicrosoftGateway(new SettingsMetadataGateway($settings), $crypto);
$this->assertNull($gateway->getMicrosoftSharedClientSecret());
}
public function testSetMicrosoftSharedClientSecretEncryptsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with('microsoft_shared_client_secret_enc', 'enc-value', 'setting.microsoft_shared_client_secret_enc')
->willReturn(true);
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
$crypto->expects($this->once())
->method('encryptString')
->with('secret-value')
->willReturn('enc-value');
$gateway = new SettingsMicrosoftGateway(new SettingsMetadataGateway($settings), $crypto);
$this->assertTrue($gateway->setMicrosoftSharedClientSecret('secret-value'));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsSmtpGateway;
use PHPUnit\Framework\TestCase;
class SettingsSmtpGatewayTest extends TestCase
{
public function testGetSmtpSecureReturnsNullForNone(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
if ($key === 'smtp_secure') {
return 'none';
}
return null;
});
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
$this->assertNull($gateway->getSmtpSecure());
}
public function testSetSmtpSecureRejectsInvalidValue(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setSmtpSecure('starttls'));
}
public function testSetSmtpHostPersistsNullForEmptyString(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with('smtp_host', null, 'setting.smtp_host')
->willReturn(true);
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->setSmtpHost(' '));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
use PHPUnit\Framework\TestCase;
class SettingsSystemAuditGatewayTest extends TestCase
{
public function testIsSystemAuditEnabledUsesFallbackWhenMissing(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn(null);
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->isSystemAuditEnabled());
}
public function testRetentionFallsBackWhenOutOfRange(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('5');
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
$this->assertSame(365, $gateway->getSystemAuditRetentionDays());
}
public function testSetSystemAuditRetentionRejectsOutOfRange(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->never())->method('set');
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setSystemAuditRetentionDays(10));
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
use PHPUnit\Framework\TestCase;
class SettingsUserLifecycleGatewayTest extends TestCase
{
public function testSetUserInactivityDeleteDaysRequiresDeactivatePolicy(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
if ($key === 'user_inactivity_deactivate_days') {
return '0';
}
return null;
});
$settings->expects($this->never())->method('set');
$gateway = new SettingsUserLifecycleGateway(new SettingsMetadataGateway($settings));
$this->assertFalse($gateway->setUserInactivityDeleteDays(30));
}
public function testSetUserInactivityDeactivateDaysSetsDeleteToZeroWhenDisabled(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->exactly(2))->method('set')->willReturn(true);
$gateway = new SettingsUserLifecycleGateway(new SettingsMetadataGateway($settings));
$this->assertTrue($gateway->setUserInactivityDeactivateDays(0));
}
public function testGetUserInactivityDeleteDaysReturnsZeroWhenDeactivateDisabled(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
if ($key === 'user_inactivity_deactivate_days') {
return '0';
}
if ($key === 'user_inactivity_delete_days') {
return '180';
}
return null;
});
$gateway = new SettingsUserLifecycleGateway(new SettingsMetadataGateway($settings));
$this->assertSame(0, $gateway->getUserInactivityDeleteDays());
}
}

View File

@@ -0,0 +1,479 @@
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserDirectoryGateway;
use MintyPHP\Service\User\UserPasswordService;
use MintyPHP\Service\User\UserScopeGateway;
use MintyPHP\Service\User\UserSettingsGateway;
use PHPUnit\Framework\TestCase;
class UserAccountServiceTest extends TestCase
{
public function testListPagedDropsTenantUserIdForGlobalAccess(): void
{
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->expects($this->once())
->method('hasGlobalAccess')
->with(99)
->willReturn(true);
$listQueryRepository = $this->createMock(UserListQueryRepositoryInterface::class);
$listQueryRepository->expects($this->once())
->method('listPaged')
->with($this->callback(static function (array $options): bool {
return !isset($options['tenantUserId'])
&& (int) ($options['page'] ?? 0) === 2;
}))
->willReturn(['rows' => [], 'total' => 0]);
$service = $this->newService(null, null, $listQueryRepository, null, null, null, $scopeGateway);
$result = $service->listPaged(['page' => 2, 'tenantUserId' => 99]);
$this->assertSame(0, (int) ($result['total'] ?? -1));
}
public function testDeleteByUuidReturnsNotFoundForEmptyUuid(): void
{
$service = $this->newService();
$result = $service->deleteByUuid('');
$this->assertFalse($result['ok']);
$this->assertSame(404, (int) ($result['status'] ?? 0));
$this->assertSame('not_found', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidPreventsSelfDelete(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('user-uuid')
->willReturn(['id' => 10, 'uuid' => 'user-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('delete');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->deleteByUuid('user-uuid', 10);
$this->assertFalse($result['ok']);
$this->assertSame(400, (int) ($result['status'] ?? 0));
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidDeletesAndRecordsAuditEvent(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('user-uuid')
->willReturn(['id' => 11, 'uuid' => 'user-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('delete')
->with(11)
->willReturn(true);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.delete',
'success',
$this->callback(static function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 44
&& (int) ($context['target_id'] ?? 0) === 11
&& (string) ($context['target_uuid'] ?? '') === 'user-uuid';
})
);
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, null, null, $auditService);
$result = $service->deleteByUuid('user-uuid', 44);
$this->assertTrue($result['ok']);
$this->assertSame(11, (int) (($result['user'] ?? [])['id'] ?? 0));
}
public function testDeleteByUuidsReturnsPermissionDeniedWhenNothingIsInScope(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 51, 'uuid' => 'other-uuid']);
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('canAccess')->willReturn(false);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('deleteByUuids');
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway);
$result = $service->deleteByUuids(['other-uuid'], 99);
$this->assertFalse($result['ok']);
$this->assertSame('permission_denied', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidsReturnsSelfDeleteWhenOnlyOwnUuidRemains(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('self-uuid')
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
$readRepository->expects($this->once())
->method('find')
->with(99)
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('deleteByUuids');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->deleteByUuids(['self-uuid'], 99);
$this->assertFalse($result['ok']);
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
}
public function testSetActiveByUuidPreventsSelfDeactivate(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 42, 'uuid' => 'u-42']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('setActive');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->setActiveByUuid('u-42', false, 42);
$this->assertFalse($result['ok']);
$this->assertSame(400, (int) ($result['status'] ?? 0));
$this->assertSame('self_deactivate', (string) ($result['error'] ?? ''));
}
public function testSetActiveByUuidUpdatesAssignmentsAndAudit(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 33, 'uuid' => 'u-33', 'active' => 0]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('setActive')
->with(33, true, 90)
->willReturn(true);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('bumpAuthzVersion')
->with(33);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.activate',
'success',
$this->callback(static fn ($context): bool => is_array($context))
);
$service = $this->newService($readRepository, $writeRepository, null, $assignmentService, null, null, null, null, $auditService);
$result = $service->setActiveByUuid('u-33', true, 90);
$this->assertTrue($result['ok']);
}
public function testSetActiveByUuidsBumpsAuthzVersionForResolvedUserIds(): void
{
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('setActiveByUuids')
->with(['u-1', 'u-2'], false, 7)
->willReturn(true);
$writeRepository->expects($this->once())
->method('bumpAuthzVersionByUserIds')
->with([11, 12])
->willReturn(2);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturnMap([
['u-1', ['id' => 11, 'uuid' => 'u-1']],
['u-2', ['id' => 12, 'uuid' => 'u-2']],
]);
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('canAccess')->willReturn(true);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.bulk_update',
'success',
$this->callback(static fn ($context): bool => is_array($context))
);
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway, null, $auditService);
$result = $service->setActiveByUuids(['u-1', 'u-2'], false, 7);
$this->assertTrue($result['ok']);
$this->assertSame(2, (int) ($result['count'] ?? 0));
}
public function testRegisterCreatesUserAndAssignsDefaultEntities(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getAppTheme')->willReturn('dark');
$settingsGateway->method('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('create')
->with($this->callback(static function (array $data): bool {
return (string) ($data['email'] ?? '') === 'user@example.com'
&& (string) ($data['theme'] ?? '') === 'dark'
&& (int) ($data['primary_tenant_id'] ?? 0) === 3
&& (int) ($data['active'] ?? 0) === 1;
}))
->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3]);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4]);
$assignmentService->expects($this->once())->method('syncDepartments')->with(77, [5]);
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertTrue($result['ok']);
}
public function testUpdateSelfProfileReturnsErrorWhenRequestedTenantIsNotAssigned(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(15)
->willReturn([
'id' => 15,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-2')
->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(15)
->willReturn([
'tenants' => [['id' => 1, 'uuid' => 'tenant-uuid-1']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('update');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(15, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-2',
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testUpdateSelfProfilePersistsDataAndTenantSwitchWhenAllowed(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(21)
->willReturn([
'id' => 21,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'profile_description' => '',
'job_title' => '',
'phone' => '',
'mobile' => '',
'short_dial' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-9')
->willReturn(['id' => 9, 'uuid' => 'tenant-uuid-9']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(21)
->willReturn([
'tenants' => [['id' => 9, 'uuid' => 'tenant-uuid-9']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('update')
->with(21, $this->callback(static fn ($payload): bool => is_array($payload)))
->willReturn(true);
$writeRepository->expects($this->once())
->method('setCurrentTenant')
->with(21, 9)
->willReturn(true);
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(21, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-9',
'locale' => 'de',
'theme' => 'light',
]);
$this->assertTrue($result['ok']);
}
private function newService(
?UserReadRepositoryInterface $readRepository = null,
?UserWriteRepositoryInterface $writeRepository = null,
?UserListQueryRepositoryInterface $listQueryRepository = null,
?UserAssignmentService $assignmentService = null,
?UserPasswordService $passwordService = null,
?UserSettingsGateway $settingsGateway = null,
?UserScopeGateway $scopeGateway = null,
?UserDirectoryGateway $directoryGateway = null,
?SystemAuditService $auditService = null
): UserAccountService {
$readRepository = $readRepository ?? $this->createMock(UserReadRepositoryInterface::class);
$writeRepository = $writeRepository ?? $this->createMock(UserWriteRepositoryInterface::class);
$listQueryRepository = $listQueryRepository ?? $this->createMock(UserListQueryRepositoryInterface::class);
if ($assignmentService === null) {
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->method('normalizeTenantIds')->willReturn([]);
$assignmentService->method('normalizeIdInput')->willReturn([]);
$assignmentService->method('buildAssignmentsForUser')->willReturn([
'tenants' => [],
'roles' => [],
'departments' => [],
]);
$assignmentService->method('syncTenants')->willReturn(true);
$assignmentService->method('syncRoles')->willReturn(true);
$assignmentService->method('syncDepartments')->willReturn(true);
}
if ($passwordService === null) {
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
}
if ($settingsGateway === null) {
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
$settingsGateway->method('getAppTheme')->willReturn('light');
}
if ($scopeGateway === null) {
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('hasGlobalAccess')->willReturn(false);
$scopeGateway->method('canAccess')->willReturn(false);
}
if ($directoryGateway === null) {
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->method('findTenantByUuid')->willReturn(null);
}
$auditService = $auditService ?? $this->createMock(SystemAuditService::class);
return new UserAccountService(
$readRepository,
$writeRepository,
$listQueryRepository,
$assignmentService,
$passwordService,
$settingsGateway,
$scopeGateway,
$directoryGateway,
$auditService
);
}
}