agent foundation
This commit is contained in:
137
tests/Architecture/AgentSystemContractTest.php
Normal file
137
tests/Architecture/AgentSystemContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
173
tests/Architecture/FrontendTelemetryContractTest.php
Normal file
173
tests/Architecture/FrontendTelemetryContractTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
332
tests/Service/Audit/FrontendTelemetryIngestServiceTest.php
Normal file
332
tests/Service/Audit/FrontendTelemetryIngestServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
76
tests/Service/Auth/AuthSessionTenantContextServiceTest.php
Normal file
76
tests/Service/Auth/AuthSessionTenantContextServiceTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
567
tests/Service/Auth/MicrosoftOidcServiceTest.php
Normal file
567
tests/Service/Auth/MicrosoftOidcServiceTest.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
309
tests/Service/Auth/TenantSsoServiceTest.php
Normal file
309
tests/Service/Auth/TenantSsoServiceTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
89
tests/Service/Settings/SettingsApiPolicyGatewayTest.php
Normal file
89
tests/Service/Settings/SettingsApiPolicyGatewayTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
72
tests/Service/Settings/SettingsAppGatewayTest.php
Normal file
72
tests/Service/Settings/SettingsAppGatewayTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
tests/Service/Settings/SettingsDefaultsGatewayTest.php
Normal file
101
tests/Service/Settings/SettingsDefaultsGatewayTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
53
tests/Service/Settings/SettingsMicrosoftGatewayTest.php
Normal file
53
tests/Service/Settings/SettingsMicrosoftGatewayTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
46
tests/Service/Settings/SettingsSmtpGatewayTest.php
Normal file
46
tests/Service/Settings/SettingsSmtpGatewayTest.php
Normal 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(' '));
|
||||
}
|
||||
}
|
||||
38
tests/Service/Settings/SettingsSystemAuditGatewayTest.php
Normal file
38
tests/Service/Settings/SettingsSystemAuditGatewayTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
52
tests/Service/Settings/SettingsUserLifecycleGatewayTest.php
Normal file
52
tests/Service/Settings/SettingsUserLifecycleGatewayTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
479
tests/Service/User/UserAccountServiceTest.php
Normal file
479
tests/Service/User/UserAccountServiceTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user