major update

This commit is contained in:
2026-03-04 15:56:58 +01:00
parent 16759a2732
commit 8f4dd5840d
478 changed files with 24313 additions and 8201 deletions

View File

@@ -0,0 +1,150 @@
<?php
namespace MintyPHP\Tests\App\Bootstrap;
use MintyPHP\App\Bootstrap\EnvValidator;
use PHPUnit\Framework\TestCase;
final class EnvValidatorTest extends TestCase
{
public function testValidateArrayAcceptsValidDevConfiguration(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'dev',
'APP_DEBUG' => 'true',
'APP_CRYPTO_KEY' => '',
'APP_URL' => 'http://localhost:8080',
]));
$this->assertSame([], $errors);
}
public function testValidateArrayRequiresAppUrlInDevelopment(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'dev',
'APP_URL' => '',
]));
$this->assertContains('APP_URL must be set in development', $errors);
}
public function testValidateArrayRequiresProductionCryptoKeyAndUrl(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'prod',
'APP_DEBUG' => 'false',
'APP_CRYPTO_KEY' => '',
'APP_URL' => '',
]));
$this->assertContains('APP_URL must be set in production', $errors);
$this->assertContains('APP_CRYPTO_KEY must be set in production', $errors);
}
public function testValidateArrayAcceptsMissingAppUrlInTestEnv(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'test',
'APP_URL' => '',
]));
$this->assertSame([], $errors);
}
public function testValidateArrayRejectsInvalidCryptoKeyFormat(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_CRYPTO_KEY' => 'invalid-key-format',
]));
$this->assertContains('APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key', $errors);
}
public function testValidateArrayAcceptsValidHexCryptoKeyInProduction(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'prod',
'APP_DEBUG' => 'false',
'APP_URL' => 'https://example.test',
'APP_CRYPTO_KEY' => str_repeat('a', 64),
'TENANT_SCOPE_STRICT' => 'true',
]));
$this->assertSame([], $errors);
}
public function testValidateArrayRequiresHttpsAppUrlInProduction(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'prod',
'APP_DEBUG' => 'false',
'APP_URL' => 'http://example.test',
'APP_CRYPTO_KEY' => str_repeat('a', 64),
'TENANT_SCOPE_STRICT' => 'true',
]));
$this->assertContains('APP_URL must use https in production', $errors);
}
public function testValidateArrayRejectsLocalhostAppUrlInProduction(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'prod',
'APP_DEBUG' => 'false',
'APP_URL' => 'https://localhost',
'APP_CRYPTO_KEY' => str_repeat('a', 64),
'TENANT_SCOPE_STRICT' => 'true',
]));
$this->assertContains('APP_URL must not use localhost or an IP host in production', $errors);
}
public function testValidateArrayRejectsIpHostAppUrlInProduction(): void
{
$errors = EnvValidator::validateArray($this->baseEnv([
'APP_ENV' => 'prod',
'APP_DEBUG' => 'false',
'APP_URL' => 'https://127.0.0.1',
'APP_CRYPTO_KEY' => str_repeat('a', 64),
'TENANT_SCOPE_STRICT' => 'true',
]));
$this->assertContains('APP_URL must not use localhost or an IP host in production', $errors);
}
/**
* @param array<string, string> $override
* @return array<string, string>
*/
private function baseEnv(array $override = []): array
{
$storagePath = sys_get_temp_dir();
return array_merge([
'APP_NAME' => 'IMVS',
'APP_ENV' => 'dev',
'APP_DEBUG' => 'true',
'APP_TIMEZONE' => 'Europe/Berlin',
'APP_STORAGE_PATH' => $storagePath,
'APP_LOCALE' => 'de',
'APP_LOCALES' => 'de,en',
'APP_I18N_DOMAIN' => 'default',
'APP_URL' => 'http://localhost:8080',
'APP_CRYPTO_KEY' => '',
'SESSION_NAME' => 'IMVS',
'TENANT_SCOPE_STRICT' => 'true',
'DB_HOST' => 'db',
'DB_PORT' => '3306',
'DB_NAME' => 'imvs',
'DB_USER' => 'imvs',
'DB_PASS' => 'imvs',
'CACHE_SERVERS' => 'memcached:11211',
'FIREWALL_CONCURRENCY' => '10',
'FIREWALL_SPINLOCK_SECONDS' => '0.15',
'FIREWALL_INTERVAL_SECONDS' => '300',
'FIREWALL_CACHE_PREFIX' => 'fw_concurrency_',
'FIREWALL_REVERSE_PROXY' => 'false',
], $override);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AsideNavigationContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testAdminAsideUsesGroupedDetailsStorageContract(): void
{
$content = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$this->assertStringContainsString('data-aside-details-storage="aside-admin-sections-v1"', $content);
$this->assertStringContainsString('data-aside-details-open-active="1"', $content);
$this->assertStringContainsString('<details data-details-key="<?php e($groupKey); ?>"', $content);
$this->assertStringContainsString("'key' => 'admin-organization'", $content);
$this->assertStringContainsString("'key' => 'admin-roles-permissions'", $content);
$this->assertStringContainsString("'key' => 'admin-automation'", $content);
$this->assertStringContainsString("'key' => 'admin-monitoring'", $content);
$this->assertStringContainsString("'key' => 'admin-logs'", $content);
$this->assertStringContainsString("'key' => 'admin-system'", $content);
}
public function testAsidePanelsUseSharedDetailsOpenStateHelper(): void
{
$content = $this->readProjectFile('web/js/components/app-toggle-aside-panels.js');
$this->assertStringContainsString("import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';", $content);
$this->assertStringContainsString('initPersistedDetailsGroup({', $content);
$this->assertStringContainsString("panel.dataset.asideDetailsOpenActive === '1'", $content);
}
public function testDetailStateUsesSharedDetailsOpenStateHelper(): void
{
$content = $this->readProjectFile('web/js/components/app-details-state.js');
$this->assertStringContainsString("import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';", $content);
$this->assertStringContainsString('initPersistedDetailsGroup({', $content);
$this->assertStringNotContainsString('window.localStorage.setItem(storageKey, JSON.stringify(openKeys));', $content);
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthzAdminContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testUsersDataActionRequiresUsersViewPermission(): void
{
$content = $this->readProjectFile('pages/admin/users/data().php');
$this->assertStringContainsString('Guard::requireLogin();', $content);
$this->assertStringContainsString('Guard::requireAbilityOrForbidden(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW);', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content);
}
public function testUsersExportActionRequiresUsersViewPermission(): void
{
$content = $this->readProjectFile('pages/admin/users/export().php');
$this->assertStringContainsString('Guard::requireLogin();', $content);
$this->assertStringContainsString('AuthorizationService::class', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content);
}
public function testUsersActivateActionRequiresUsersUpdatePermission(): void
{
$content = $this->readProjectFile('pages/admin/users/activate($id).php');
$this->assertStringContainsString('AuthorizationService::class', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE', $content);
}
public function testUsersDeactivateActionRequiresUsersUpdatePermission(): void
{
$content = $this->readProjectFile('pages/admin/users/deactivate($id).php');
$this->assertStringContainsString('AuthorizationService::class', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE', $content);
}
public function testUsersBulkActionPermissionMappingIsConsistent(): void
{
$content = $this->readProjectFile('pages/admin/users/bulk($action).php');
$this->assertStringContainsString('AuthorizationService::class', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK', $content);
$this->assertStringContainsString("'bulk_action' => \$action", $content);
}
public function testAdditionalAdminUserActionsUseCentralPolicy(): void
{
$indexContent = $this->readProjectFile('pages/admin/users/index().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $indexContent);
$createContent = $this->readProjectFile('pages/admin/users/create().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE', $createContent);
$deleteContent = $this->readProjectFile('pages/admin/users/delete($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE', $deleteContent);
$accessPdfContent = $this->readProjectFile('pages/admin/users/access-pdf($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF', $accessPdfContent);
$accessPdfBulkContent = $this->readProjectFile('pages/admin/users/access-pdf-bulk().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK', $accessPdfBulkContent);
$tokenCreateContent = $this->readProjectFile('pages/admin/users/api-token-create($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenCreateContent);
$tokenRevokeContent = $this->readProjectFile('pages/admin/users/api-token-revoke($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenRevokeContent);
$sendAccessContent = $this->readProjectFile('pages/admin/users/send-access($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS', $sendAccessContent);
$forgetTokensContent = $this->readProjectFile('pages/admin/users/forget-tokens($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_FORGET_TOKENS', $forgetTokensContent);
}
public function testAdminUserEditUsesContextAndSubmitPolicies(): void
{
$content = $this->readProjectFile('pages/admin/users/edit($id).php');
$this->assertStringContainsString('AuthorizationService::class', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT', $content);
}
public function testAdminUserAvatarEndpointsUseCentralPolicies(): void
{
$uploadContent = $this->readProjectFile('pages/admin/users/avatar($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD', $uploadContent);
$deleteContent = $this->readProjectFile('pages/admin/users/avatar-delete($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE', $deleteContent);
$viewContent = $this->readProjectFile('pages/admin/users/avatar-file().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW', $viewContent);
}
public function testAdminTenantEditAndCustomFieldActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/tenants/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/tenants/data().php');
$this->assertStringContainsString('Guard::requireAbilityDecisionOrForbidden(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW);', $dataContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/tenants/create().php');
$this->assertStringContainsString('AuthorizationService::class', $createContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/tenants/edit($id).php');
$this->assertStringContainsString('AuthorizationService::class', $editContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT', $editContent);
$customFieldCreate = $this->readProjectFile('pages/admin/tenants/custom-field-create($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldCreate);
$customFieldUpdate = $this->readProjectFile('pages/admin/tenants/custom-field-update($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldUpdate);
$customFieldDelete = $this->readProjectFile('pages/admin/tenants/custom-field-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldDelete);
}
public function testAdminTenantDeleteUsesCentralPolicy(): void
{
$deleteContent = $this->readProjectFile('pages/admin/tenants/delete($id).php');
$this->assertStringContainsString('AuthorizationService::class', $deleteContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_DELETE', $deleteContent);
}
public function testAdminTenantMediaEndpointsUseCentralPolicies(): void
{
$avatarView = $this->readProjectFile('pages/admin/tenants/avatar-file().php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_AVATAR_VIEW', $avatarView);
$avatarUpload = $this->readProjectFile('pages/admin/tenants/avatar($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarUpload);
$avatarDelete = $this->readProjectFile('pages/admin/tenants/avatar-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarDelete);
$faviconUpload = $this->readProjectFile('pages/admin/tenants/favicon($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconUpload);
$faviconDelete = $this->readProjectFile('pages/admin/tenants/favicon-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconDelete);
}
public function testAdminDepartmentsEditAndDeleteUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/departments/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/departments/data().php');
$this->assertStringContainsString('Guard::requireAbilityDecisionOrForbidden(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW);', $dataContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/departments/create().php');
$this->assertStringContainsString('AuthorizationService::class', $createContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/departments/edit($id).php');
$this->assertStringContainsString('AuthorizationService::class', $editContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/departments/delete($id).php');
$this->assertStringContainsString('AuthorizationService::class', $deleteContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_DELETE', $deleteContent);
}
public function testAdminRolesActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/roles/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/roles/data().php');
$this->assertStringContainsString('Guard::requireAbilityOrForbidden(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW);', $dataContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/roles/create().php');
$this->assertStringContainsString('AuthorizationService::class', $createContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/roles/edit($id).php');
$this->assertStringContainsString('AuthorizationService::class', $editContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/roles/delete($id).php');
$this->assertStringContainsString('AuthorizationService::class', $deleteContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE', $deleteContent);
}
public function testAdminPermissionsActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/permissions/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/permissions/data().php');
$this->assertStringContainsString('Guard::requireAbilityOrForbidden(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW);', $dataContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/permissions/create().php');
$this->assertStringContainsString('AuthorizationService::class', $createContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/permissions/edit($id).php');
$this->assertStringContainsString('AuthorizationService::class', $editContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/permissions/delete($id).php');
$this->assertStringContainsString('AuthorizationService::class', $deleteContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE', $deleteContent);
}
public function testAdminSettingsActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/settings/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $indexContent);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE', $indexContent);
$logoUpload = $this->readProjectFile('pages/admin/settings/logo().php');
$this->assertStringContainsString('AuthorizationService::class', $logoUpload);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoUpload);
$logoDelete = $this->readProjectFile('pages/admin/settings/logo-delete().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoDelete);
$faviconUpload = $this->readProjectFile('pages/admin/settings/favicon().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconUpload);
$faviconDelete = $this->readProjectFile('pages/admin/settings/favicon-delete().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconDelete);
$revokeApiTokens = $this->readProjectFile('pages/admin/settings/revoke-api-tokens().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $revokeApiTokens);
$expireRememberTokens = $this->readProjectFile('pages/admin/settings/expire-remember-tokens().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $expireRememberTokens);
$runUserLifecycle = $this->readProjectFile('pages/admin/settings/run-user-lifecycle().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN', $runUserLifecycle);
}
public function testScheduledJobEditUsesAbilityChecksAndNoPermissionHelper(): void
{
$content = $this->readProjectFile('pages/admin/scheduled-jobs/edit($id).php');
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW', $content);
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE', $content);
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_RUN_NOW', $content);
$this->assertStringNotContainsString("can('jobs.manage')", $content);
$this->assertStringNotContainsString("can('jobs.run_now')", $content);
}
}

View File

@@ -1,471 +0,0 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthzAndApiLoginContractTest extends TestCase
{
public function testUsersDataActionRequiresUsersViewPermission(): void
{
$content = $this->readProjectFile('pages/admin/users/data().php');
$this->assertStringContainsString('Guard::requireLogin();', $content);
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content);
}
public function testUsersExportActionRequiresUsersViewPermission(): void
{
$content = $this->readProjectFile('pages/admin/users/export().php');
$this->assertStringContainsString('Guard::requireLogin();', $content);
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content);
}
public function testUsersActivateActionRequiresUsersUpdatePermission(): void
{
$content = $this->readProjectFile('pages/admin/users/activate($id).php');
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE', $content);
}
public function testUsersDeactivateActionRequiresUsersUpdatePermission(): void
{
$content = $this->readProjectFile('pages/admin/users/deactivate($id).php');
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE', $content);
}
public function testUsersBulkActionPermissionMappingIsConsistent(): void
{
$content = $this->readProjectFile('pages/admin/users/bulk($action).php');
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK', $content);
$this->assertStringContainsString("'bulk_action' => \$action", $content);
}
public function testAdditionalAdminUserActionsUseCentralPolicy(): void
{
$indexContent = $this->readProjectFile('pages/admin/users/index().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $indexContent);
$createContent = $this->readProjectFile('pages/admin/users/create().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE', $createContent);
$deleteContent = $this->readProjectFile('pages/admin/users/delete($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE', $deleteContent);
$accessPdfContent = $this->readProjectFile('pages/admin/users/access-pdf($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF', $accessPdfContent);
$accessPdfBulkContent = $this->readProjectFile('pages/admin/users/access-pdf-bulk().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK', $accessPdfBulkContent);
$tokenCreateContent = $this->readProjectFile('pages/admin/users/api-token-create($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenCreateContent);
$tokenRevokeContent = $this->readProjectFile('pages/admin/users/api-token-revoke($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenRevokeContent);
$sendAccessContent = $this->readProjectFile('pages/admin/users/send-access($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS', $sendAccessContent);
$forgetTokensContent = $this->readProjectFile('pages/admin/users/forget-tokens($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_FORGET_TOKENS', $forgetTokensContent);
}
public function testAdminUserEditUsesContextAndSubmitPolicies(): void
{
$content = $this->readProjectFile('pages/admin/users/edit($id).php');
$this->assertStringContainsString('createAuthorizationService()', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT', $content);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT', $content);
}
public function testAdminUserAvatarEndpointsUseCentralPolicies(): void
{
$uploadContent = $this->readProjectFile('pages/admin/users/avatar($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD', $uploadContent);
$deleteContent = $this->readProjectFile('pages/admin/users/avatar-delete($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE', $deleteContent);
$viewContent = $this->readProjectFile('pages/admin/users/avatar-file().php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW', $viewContent);
}
public function testAdminTenantEditAndCustomFieldActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/tenants/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/tenants/data().php');
$this->assertStringContainsString('createAuthorizationService()', $dataContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/tenants/create().php');
$this->assertStringContainsString('createAuthorizationService()', $createContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/tenants/edit($id).php');
$this->assertStringContainsString('createAuthorizationService()', $editContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT', $editContent);
$customFieldCreate = $this->readProjectFile('pages/admin/tenants/custom-field-create($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldCreate);
$customFieldUpdate = $this->readProjectFile('pages/admin/tenants/custom-field-update($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldUpdate);
$customFieldDelete = $this->readProjectFile('pages/admin/tenants/custom-field-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldDelete);
}
public function testAdminTenantTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/tenants/index(default).phtml');
$this->assertStringNotContainsString("can('tenants.create')", $indexTemplate);
$this->assertStringContainsString('$canCreateTenants', $indexTemplate);
$createTemplate = $this->readProjectFile('pages/admin/tenants/create(default).phtml');
$this->assertStringNotContainsString("can('custom_fields.manage')", $createTemplate);
$this->assertStringNotContainsString("can('tenants.sso_manage')", $createTemplate);
$this->assertStringContainsString('$canManageCustomFields', $createTemplate);
$this->assertStringContainsString('$canManageSso', $createTemplate);
}
public function testAdminTenantDeleteUsesCentralPolicy(): void
{
$deleteContent = $this->readProjectFile('pages/admin/tenants/delete($id).php');
$this->assertStringContainsString('createAuthorizationService()', $deleteContent);
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_DELETE', $deleteContent);
}
public function testAdminTenantMediaEndpointsUseCentralPolicies(): void
{
$avatarView = $this->readProjectFile('pages/admin/tenants/avatar-file().php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_AVATAR_VIEW', $avatarView);
$avatarUpload = $this->readProjectFile('pages/admin/tenants/avatar($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarUpload);
$avatarDelete = $this->readProjectFile('pages/admin/tenants/avatar-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarDelete);
$faviconUpload = $this->readProjectFile('pages/admin/tenants/favicon($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconUpload);
$faviconDelete = $this->readProjectFile('pages/admin/tenants/favicon-delete($id).php');
$this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconDelete);
}
public function testAdminDepartmentsEditAndDeleteUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/departments/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/departments/data().php');
$this->assertStringContainsString('createAuthorizationService()', $dataContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/departments/create().php');
$this->assertStringContainsString('createAuthorizationService()', $createContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/departments/edit($id).php');
$this->assertStringContainsString('createAuthorizationService()', $editContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/departments/delete($id).php');
$this->assertStringContainsString('createAuthorizationService()', $deleteContent);
$this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_DELETE', $deleteContent);
}
public function testAdminDepartmentsListTemplateUsesServerCapabilities(): void
{
$content = $this->readProjectFile('pages/admin/departments/index(default).phtml');
$this->assertStringNotContainsString("can('departments.create')", $content);
$this->assertStringContainsString('$canCreateDepartments', $content);
}
public function testAdminRolesActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/roles/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/roles/data().php');
$this->assertStringContainsString('createAuthorizationService()', $dataContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/roles/create().php');
$this->assertStringContainsString('createAuthorizationService()', $createContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/roles/edit($id).php');
$this->assertStringContainsString('createAuthorizationService()', $editContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/roles/delete($id).php');
$this->assertStringContainsString('createAuthorizationService()', $deleteContent);
$this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE', $deleteContent);
}
public function testAdminPermissionsActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/permissions/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $indexContent);
$dataContent = $this->readProjectFile('pages/admin/permissions/data().php');
$this->assertStringContainsString('createAuthorizationService()', $dataContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $dataContent);
$createContent = $this->readProjectFile('pages/admin/permissions/create().php');
$this->assertStringContainsString('createAuthorizationService()', $createContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_CREATE', $createContent);
$editContent = $this->readProjectFile('pages/admin/permissions/edit($id).php');
$this->assertStringContainsString('createAuthorizationService()', $editContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT', $editContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT', $editContent);
$deleteContent = $this->readProjectFile('pages/admin/permissions/delete($id).php');
$this->assertStringContainsString('createAuthorizationService()', $deleteContent);
$this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE', $deleteContent);
}
public function testAdminRoleTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/roles/index(default).phtml');
$this->assertStringNotContainsString("can('roles.create')", $indexTemplate);
$this->assertStringContainsString('$canCreateRoles', $indexTemplate);
$editTemplate = $this->readProjectFile('pages/admin/roles/edit(default).phtml');
$this->assertStringNotContainsString("can('roles.update')", $editTemplate);
$this->assertStringNotContainsString("can('roles.delete')", $editTemplate);
$this->assertStringContainsString('$canUpdateRole', $editTemplate);
$this->assertStringContainsString('$canDeleteRole', $editTemplate);
}
public function testAdminPermissionTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/permissions/index(default).phtml');
$this->assertStringNotContainsString("can('permissions.create')", $indexTemplate);
$this->assertStringContainsString('$canCreatePermissions', $indexTemplate);
$editTemplate = $this->readProjectFile('pages/admin/permissions/edit(default).phtml');
$this->assertStringNotContainsString("can('permissions.update')", $editTemplate);
$this->assertStringNotContainsString("can('permissions.delete')", $editTemplate);
$this->assertStringContainsString('$canUpdatePermission', $editTemplate);
$this->assertStringContainsString('$canDeletePermission', $editTemplate);
}
public function testAdminSettingsActionsUseCentralPolicies(): void
{
$indexContent = $this->readProjectFile('pages/admin/settings/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $indexContent);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE', $indexContent);
$logoUpload = $this->readProjectFile('pages/admin/settings/logo().php');
$this->assertStringContainsString('createAuthorizationService()', $logoUpload);
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoUpload);
$logoDelete = $this->readProjectFile('pages/admin/settings/logo-delete().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoDelete);
$faviconUpload = $this->readProjectFile('pages/admin/settings/favicon().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconUpload);
$faviconDelete = $this->readProjectFile('pages/admin/settings/favicon-delete().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconDelete);
$revokeApiTokens = $this->readProjectFile('pages/admin/settings/revoke-api-tokens().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $revokeApiTokens);
$expireRememberTokens = $this->readProjectFile('pages/admin/settings/expire-remember-tokens().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $expireRememberTokens);
$runUserLifecycle = $this->readProjectFile('pages/admin/settings/run-user-lifecycle().php');
$this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN', $runUserLifecycle);
}
public function testAdminSettingsTemplateUsesServerCapabilities(): void
{
$templateContent = $this->readProjectFile('pages/admin/settings/index(default).phtml');
$this->assertStringNotContainsString("can('settings.update')", $templateContent);
$this->assertStringContainsString('$canUpdateSettings', $templateContent);
}
public function testAdminUserEditTemplateUsesServerCapabilities(): void
{
$content = $this->readProjectFile('pages/admin/users/edit(default).phtml');
$this->assertStringNotContainsString("can('users.", $content);
$this->assertStringNotContainsString("can('permissions.view')", $content);
$this->assertStringContainsString('$canViewSecurityArtifacts', $content);
}
public function testApiLoginIsBootstrappedAsPublic(): void
{
$content = $this->readProjectFile('pages/api/v1/auth/login().php');
$this->assertStringContainsString('ApiBootstrap::init(false);', $content);
}
public function testApiBootstrapSupportsOptionalAuthRequirement(): void
{
$content = $this->readProjectFile('lib/Http/ApiBootstrap.php');
$this->assertStringContainsString('public static function init(bool $requireAuth = true): void', $content);
$this->assertStringContainsString('if ($requireAuth) {', $content);
$this->assertStringContainsString('$authenticated = ApiAuth::authenticate();', $content);
$this->assertStringContainsString('ApiResponse::unauthorized();', $content);
}
public function testApiUsersEndpointsUseCentralUserPolicy(): void
{
$indexContent = $this->readProjectFile('pages/api/v1/users/index().php');
$this->assertStringContainsString('createAuthorizationService()', $indexContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_GET', $indexContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST', $indexContent);
$showContent = $this->readProjectFile('pages/api/v1/users/show($id).php');
$this->assertStringContainsString('createAuthorizationService()', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_DELETE', $showContent);
}
public function testApiPermissionAndSettingsEndpointsHaveExpectedGuards(): void
{
$permissionContent = $this->readProjectFile('pages/api/v1/permissions/index().php');
$this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::PERMISSIONS_VIEW);", $permissionContent);
$this->assertStringContainsString("foreach ((\$result['data'] ?? []) as \$row)", $permissionContent);
$settingsContent = $this->readProjectFile('pages/api/v1/settings/index().php');
$this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::SETTINGS_VIEW);", $settingsContent);
$this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::SETTINGS_UPDATE);", $settingsContent);
}
public function testApiMeEndpointsUseSelfServicePermissions(): void
{
$meContent = $this->readProjectFile('pages/api/v1/me/index().php');
$this->assertStringContainsString('$method === \'PUT\' || $method === \'PATCH\'', $meContent);
$this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $meContent);
$this->assertStringContainsString("ApiResponse::validationError(['profile' => array_values(\$errors)]);", $meContent);
$passwordContent = $this->readProjectFile('pages/api/v1/me/password().php');
$this->assertStringContainsString("ApiResponse::requireMethod('POST');", $passwordContent);
$this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $passwordContent);
$avatarContent = $this->readProjectFile('pages/api/v1/me/avatar().php');
$this->assertStringContainsString("define('MINTY_ALLOW_OUTPUT', true);", $avatarContent);
$this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $avatarContent);
}
public function testApiAvatarEndpointsUseExpectedAuthorizationModel(): void
{
$userAvatarContent = $this->readProjectFile('pages/api/v1/users/avatar($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $userAvatarContent);
$tenantAvatarContent = $this->readProjectFile('pages/api/v1/tenants/avatar($id).php');
$this->assertStringContainsString('PermissionService::TENANTS_VIEW', $tenantAvatarContent);
$this->assertStringContainsString("ApiAuth::requireResourceAccess('tenants', \$tenantId);", $tenantAvatarContent);
}
public function testApiTenantShowExposesFullTenantMasterData(): void
{
$tenantShowContent = $this->readProjectFile('pages/api/v1/tenants/show($id).php');
$this->assertStringContainsString("'region' => \$tenant['region'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'vat_id' => \$tenant['vat_id'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'support_email' => \$tenant['support_email'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'billing_email' => \$tenant['billing_email'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'primary_color_use_default' => \$primaryColor === ''", $tenantShowContent);
$this->assertStringContainsString("'allow_user_theme_mode' => \$allowUserThemeMode", $tenantShowContent);
$openApiContent = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('TenantShowResponse:', $openApiContent);
$this->assertStringContainsString('primary_color_use_default:', $openApiContent);
$this->assertStringContainsString('allow_user_theme_mode:', $openApiContent);
$this->assertStringContainsString('support_email:', $openApiContent);
}
public function testApiUserWriteEndpointsUseUuidAssignmentInput(): void
{
$usersIndex = $this->readProjectFile('pages/api/v1/users/index().php');
$this->assertStringContainsString('normalizeUserWriteInputToInternalIds($input)', $usersIndex);
$this->assertStringContainsString("'tenant_ids' => 'use_tenant_uuids'", $usersIndex);
$this->assertStringContainsString("'role_ids' => 'use_role_uuids'", $usersIndex);
$this->assertStringContainsString("'department_ids' => 'use_department_uuids'", $usersIndex);
$usersShow = $this->readProjectFile('pages/api/v1/users/show($id).php');
$this->assertStringContainsString('normalizeUserWriteInputToInternalIds($input)', $usersShow);
$this->assertStringContainsString("'primary_tenant_id' => 'use_primary_tenant_uuid'", $usersShow);
}
public function testApiDepartmentEndpointsUseTenantUuidAndNoTenantIdExposure(): void
{
$departmentIndex = $this->readProjectFile('pages/api/v1/departments/index().php');
$this->assertStringContainsString("ApiResponse::validationError(['tenant_id' => ['use_tenant_uuid']]);", $departmentIndex);
$this->assertStringContainsString("if (array_key_exists('tenant_uuid', \$input)) {", $departmentIndex);
$departmentShow = $this->readProjectFile('pages/api/v1/departments/show($id).php');
$this->assertStringContainsString("'tenant_uuid' => \$tenantUuid", $departmentShow);
$this->assertStringNotContainsString("'tenant_id' => \$department['tenant_id'] ?? null", $departmentShow);
$this->assertStringContainsString("'cost_center' => \$department['cost_center'] ?? ''", $departmentShow);
}
public function testApiRoleShowIncludesPermissionKeys(): void
{
$roleShow = $this->readProjectFile('pages/api/v1/roles/show($id).php');
$this->assertStringContainsString('createRolePermissionRepository()', $roleShow);
$this->assertStringContainsString('listPermissionKeysByRoleIds([$roleId])', $roleShow);
$this->assertStringContainsString("'permission_keys' => \$permissionKeys", $roleShow);
}
public function testOpenApiMarksLoginAsPublic(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertMatchesRegularExpression('/\/auth\/login:\s+post:.*?security:\s*\[\]/s', $content);
$this->assertStringContainsString('no Authorization header required', $content);
}
public function testOpenApiIncludesNewCriticalFrontendEndpoints(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('/permissions:', $content);
$this->assertStringContainsString('/me/password:', $content);
$this->assertStringContainsString('/me/avatar:', $content);
$this->assertStringContainsString('/users/avatar/{uuid}:', $content);
$this->assertStringContainsString('/tenants/avatar/{uuid}:', $content);
$this->assertStringContainsString('/settings:', $content);
$this->assertStringContainsString('/settings/public:', $content);
}
public function testOpenApiUsesUuidBasedMasterDataContracts(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('required: [description, tenant_uuid]', $content);
$this->assertStringContainsString('tenant_uuids:', $content);
$this->assertStringContainsString('primary_tenant_uuid:', $content);
$this->assertStringContainsString('role_uuids:', $content);
$this->assertStringContainsString('department_uuids:', $content);
$this->assertStringContainsString('permission_keys:', $content);
$this->assertStringContainsString('tenant_uuid:', $content);
}
private function readProjectFile(string $path): string
{
$root = realpath(__DIR__ . '/../..');
$this->assertNotFalse($root, 'Project root not found.');
$fullPath = $root . '/' . $path;
$this->assertFileExists($fullPath, 'File not found: ' . $path);
$content = file_get_contents($fullPath);
$this->assertNotFalse($content, 'Could not read file: ' . $path);
return $content;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthzApiContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testApiLoginIsBootstrappedAsPublic(): void
{
$content = $this->readProjectFile('pages/api/v1/auth/login().php');
$this->assertStringContainsString('ApiBootstrap::init(false);', $content);
}
public function testApiBootstrapSupportsOptionalAuthRequirement(): void
{
$content = $this->readProjectFile('lib/Http/ApiBootstrap.php');
$this->assertStringContainsString('public static function init(bool $requireAuth = true): void', $content);
$this->assertStringContainsString('if ($requireAuth) {', $content);
$this->assertStringContainsString('$authenticated = ApiAuth::authenticate();', $content);
$this->assertStringContainsString('ApiResponse::unauthorized();', $content);
}
public function testApiUsersEndpointsUseCentralUserPolicy(): void
{
$indexContent = $this->readProjectFile('pages/api/v1/users/index().php');
$this->assertStringContainsString('AuthorizationService::class', $indexContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_GET', $indexContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST', $indexContent);
$showContent = $this->readProjectFile('pages/api/v1/users/show($id).php');
$this->assertStringContainsString('AuthorizationService::class', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE', $showContent);
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_DELETE', $showContent);
}
public function testApiPermissionAndSettingsEndpointsHaveExpectedGuards(): void
{
$permissionContent = $this->readProjectFile('pages/api/v1/permissions/index().php');
$this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_PERMISSIONS_VIEW);', $permissionContent);
$this->assertStringContainsString("foreach ((\$result['rows'] ?? []) as \$row)", $permissionContent);
$settingsContent = $this->readProjectFile('pages/api/v1/settings/index().php');
$this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_SETTINGS_VIEW);', $settingsContent);
$this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_SETTINGS_UPDATE);', $settingsContent);
}
public function testApiMeEndpointsUseSelfServicePermissions(): void
{
$meContent = $this->readProjectFile('pages/api/v1/me/index().php');
$this->assertStringContainsString('$method === \'PUT\' || $method === \'PATCH\'', $meContent);
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE', $meContent);
$this->assertStringContainsString('ApiResponse::validationFromFormErrors(', $meContent);
$passwordContent = $this->readProjectFile('pages/api/v1/me/password().php');
$this->assertStringContainsString("ApiResponse::requireMethod('POST');", $passwordContent);
$this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE);', $passwordContent);
$avatarContent = $this->readProjectFile('pages/api/v1/me/avatar().php');
$this->assertStringContainsString("define('MINTY_ALLOW_OUTPUT', true);", $avatarContent);
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE', $avatarContent);
}
public function testApiAvatarEndpointsUseExpectedAuthorizationModel(): void
{
$userAvatarContent = $this->readProjectFile('pages/api/v1/users/avatar($id).php');
$this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $userAvatarContent);
$tenantAvatarContent = $this->readProjectFile('pages/api/v1/tenants/avatar($id).php');
$this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_TENANTS_VIEW', $tenantAvatarContent);
$this->assertStringContainsString("ApiAuth::requireResourceAccess('tenants', \$tenantId);", $tenantAvatarContent);
}
public function testApiTenantShowExposesFullTenantMasterData(): void
{
$tenantShowContent = $this->readProjectFile('pages/api/v1/tenants/show($id).php');
$this->assertStringContainsString("'region' => \$tenant['region'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'vat_id' => \$tenant['vat_id'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'support_email' => \$tenant['support_email'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'billing_email' => \$tenant['billing_email'] ?? ''", $tenantShowContent);
$this->assertStringContainsString("'primary_color_use_default' => \$primaryColor === ''", $tenantShowContent);
$this->assertStringContainsString("'allow_user_theme_mode' => \$allowUserThemeMode", $tenantShowContent);
$openApiContent = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('TenantShowResponse:', $openApiContent);
$this->assertStringContainsString('primary_color_use_default:', $openApiContent);
$this->assertStringContainsString('allow_user_theme_mode:', $openApiContent);
$this->assertStringContainsString('support_email:', $openApiContent);
}
public function testApiUserWriteEndpointsUseUuidAssignmentInput(): void
{
$usersIndex = $this->readProjectFile('pages/api/v1/users/index().php');
$this->assertStringContainsString('UserApiWriteInputMapper::class', $usersIndex);
$this->assertStringContainsString('->normalize($input)', $usersIndex);
$usersShow = $this->readProjectFile('pages/api/v1/users/show($id).php');
$this->assertStringContainsString('UserApiWriteInputMapper::class', $usersShow);
$this->assertStringContainsString('->normalize($input)', $usersShow);
$mapper = $this->readProjectFile('lib/Service/User/UserApiWriteInputMapper.php');
$this->assertStringContainsString("'tenant_ids' => 'use_tenant_uuids'", $mapper);
$this->assertStringContainsString("'role_ids' => 'use_role_uuids'", $mapper);
$this->assertStringContainsString("'department_ids' => 'use_department_uuids'", $mapper);
$this->assertStringContainsString("'primary_tenant_id' => 'use_primary_tenant_uuid'", $mapper);
}
public function testApiDepartmentEndpointsUseTenantUuidAndNoTenantIdExposure(): void
{
$departmentIndex = $this->readProjectFile('pages/api/v1/departments/index().php');
$this->assertStringContainsString("ApiResponse::validationFromFormErrors(formErrorsFrom(['tenant_id' => ['use_tenant_uuid']]));", $departmentIndex);
$this->assertStringContainsString("if (array_key_exists('tenant_uuid', \$input)) {", $departmentIndex);
$departmentShow = $this->readProjectFile('pages/api/v1/departments/show($id).php');
$this->assertStringContainsString("'tenant_uuid' => \$tenantUuid", $departmentShow);
$this->assertStringNotContainsString("'tenant_id' => \$department['tenant_id'] ?? null", $departmentShow);
$this->assertStringContainsString("'cost_center' => \$department['cost_center'] ?? ''", $departmentShow);
}
public function testApiRoleShowIncludesPermissionKeys(): void
{
$roleShow = $this->readProjectFile('pages/api/v1/roles/show($id).php');
$this->assertStringContainsString('RolePermissionRepository::class', $roleShow);
$this->assertStringContainsString('listPermissionKeysByRoleIds([$roleId])', $roleShow);
$this->assertStringContainsString("'permission_keys' => \$permissionKeys", $roleShow);
}
public function testOpenApiMarksLoginAsPublic(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertMatchesRegularExpression('/\/auth\/login:\s+post:.*?security:\s*\[\]/s', $content);
$this->assertStringContainsString('no Authorization header required', $content);
}
public function testOpenApiIncludesNewCriticalFrontendEndpoints(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('/permissions:', $content);
$this->assertStringContainsString('/me/password:', $content);
$this->assertStringContainsString('/me/avatar:', $content);
$this->assertStringContainsString('/users/avatar/{uuid}:', $content);
$this->assertStringContainsString('/tenants/avatar/{uuid}:', $content);
$this->assertStringContainsString('/settings:', $content);
$this->assertStringContainsString('/settings/public:', $content);
}
public function testOpenApiUsesUuidBasedMasterDataContracts(): void
{
$content = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('required: [description, tenant_uuid]', $content);
$this->assertStringContainsString('tenant_uuids:', $content);
$this->assertStringContainsString('primary_tenant_uuid:', $content);
$this->assertStringContainsString('role_uuids:', $content);
$this->assertStringContainsString('department_uuids:', $content);
$this->assertStringContainsString('permission_keys:', $content);
$this->assertStringContainsString('tenant_uuid:', $content);
}
public function testApiErrorEnvelopeIsCentralizedAndRequestIdAware(): void
{
$apiResponse = $this->readProjectFile('lib/Http/ApiResponse.php');
$this->assertStringContainsString("'ok' => false", $apiResponse);
$this->assertStringContainsString("'error_code' => \$normalizedErrorCode", $apiResponse);
$this->assertStringContainsString("'details' => \$normalizedDetails", $apiResponse);
$this->assertStringContainsString("header('X-Request-Id: ' . \$requestId);", $apiResponse);
$this->assertStringNotContainsString("'error' => \$normalizedErrorCode", $apiResponse);
$this->assertStringNotContainsString("\$body['errors']", $apiResponse);
$openApi = $this->readProjectFile('docs/openapi.yaml');
$this->assertStringContainsString('required: [ok, request_id, error_code, details]', $openApi);
$this->assertStringNotContainsString('Legacy alias of `error_code`', $openApi);
$this->assertStringNotContainsString('Legacy alias of `details.errors`', $openApi);
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthzUiContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testAdminTenantTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/tenants/index(default).phtml');
$this->assertStringNotContainsString("can('tenants.create')", $indexTemplate);
$this->assertStringContainsString('$canCreateTenants', $indexTemplate);
$createTemplate = $this->readProjectFile('pages/admin/tenants/create(default).phtml');
$this->assertStringNotContainsString("can('custom_fields.manage')", $createTemplate);
$this->assertStringNotContainsString("can('tenants.sso_manage')", $createTemplate);
$this->assertStringContainsString('$canManageCustomFields', $createTemplate);
$this->assertStringContainsString('$canManageSso', $createTemplate);
}
public function testAdminDepartmentsListTemplateUsesServerCapabilities(): void
{
$content = $this->readProjectFile('pages/admin/departments/index(default).phtml');
$this->assertStringNotContainsString("can('departments.create')", $content);
$this->assertStringContainsString('$canCreateDepartments', $content);
}
public function testAdminRoleTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/roles/index(default).phtml');
$this->assertStringNotContainsString("can('roles.create')", $indexTemplate);
$this->assertStringContainsString('$canCreateRoles', $indexTemplate);
$editTemplate = $this->readProjectFile('pages/admin/roles/edit(default).phtml');
$this->assertStringNotContainsString("can('roles.update')", $editTemplate);
$this->assertStringNotContainsString("can('roles.delete')", $editTemplate);
$this->assertStringContainsString('$canUpdateRole', $editTemplate);
$this->assertStringContainsString('$canDeleteRole', $editTemplate);
}
public function testAdminPermissionTemplatesUseServerCapabilities(): void
{
$indexTemplate = $this->readProjectFile('pages/admin/permissions/index(default).phtml');
$this->assertStringNotContainsString("can('permissions.create')", $indexTemplate);
$this->assertStringContainsString('$canCreatePermissions', $indexTemplate);
$editTemplate = $this->readProjectFile('pages/admin/permissions/edit(default).phtml');
$this->assertStringNotContainsString("can('permissions.update')", $editTemplate);
$this->assertStringNotContainsString("can('permissions.delete')", $editTemplate);
$this->assertStringContainsString('$canUpdatePermission', $editTemplate);
$this->assertStringContainsString('$canDeletePermission', $editTemplate);
}
public function testAdminSettingsTemplateUsesServerCapabilities(): void
{
$templateContent = $this->readProjectFile('pages/admin/settings/index(default).phtml');
$this->assertStringNotContainsString("can('settings.update')", $templateContent);
$this->assertStringContainsString('$canUpdateSettings', $templateContent);
}
public function testAdminUserEditTemplateUsesServerCapabilities(): void
{
$content = $this->readProjectFile('pages/admin/users/edit(default).phtml');
$this->assertStringNotContainsString("can('users.", $content);
$this->assertStringNotContainsString("can('permissions.view')", $content);
$this->assertStringContainsString('$canViewSecurityArtifacts', $content);
}
public function testHardCutTemplatesDoNotUseLegacyCanHelper(): void
{
$templates = [
'templates/partials/app-main-aside.phtml',
'templates/partials/app-main-aside-icon-bar.phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/stats/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/view(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/users/create(default).phtml',
'pages/admin/tenants/edit(default).phtml',
'pages/admin/departments/edit(default).phtml',
];
foreach ($templates as $template) {
$content = $this->readProjectFile($template);
$this->assertStringNotContainsString("can('", $content, $template);
}
}
public function testLayoutPartialsUseViewAuthLayoutCapabilities(): void
{
$defaultTemplate = $this->readProjectFile('templates/default.phtml');
$this->assertStringContainsString("\$viewAuth['layout']", $defaultTemplate);
$this->assertStringNotContainsString('UiAccessService::class', $defaultTemplate);
$aside = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$this->assertStringContainsString("\$viewAuth['layout']", $aside);
$iconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml');
$this->assertStringContainsString("\$viewAuth['layout']", $iconBar);
}
public function testHardCutActionsProvideViewAuthPageCapabilities(): void
{
$actions = [
'pages/admin/users/index().php',
'pages/admin/users/create().php',
'pages/admin/tenants/edit($id).php',
'pages/admin/departments/edit($id).php',
'pages/admin/stats/index().php',
'pages/admin/api-audit/index().php',
'pages/admin/system-audit/index().php',
'pages/admin/import-audit/index().php',
'pages/admin/scheduled-jobs/index().php',
'pages/admin/user-lifecycle-audit/index().php',
'pages/admin/user-lifecycle-audit/view($id).php',
];
foreach ($actions as $action) {
$content = $this->readProjectFile($action);
$this->assertStringContainsString("\$viewAuth['page']", $content, $action);
}
}
public function testMapDrivenHardCutActionsUseUiCapabilityMaps(): void
{
$actions = [
'pages/admin/users/index().php' => 'UiCapabilityMap::PAGE_USERS_INDEX',
'pages/admin/users/create().php' => 'UiCapabilityMap::PAGE_USERS_CREATE',
'pages/admin/stats/index().php' => 'UiCapabilityMap::PAGE_STATS_INDEX',
'pages/admin/api-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE',
'pages/admin/system-audit/index().php' => 'UiCapabilityMap::PAGE_SYSTEM_AUDIT',
'pages/admin/import-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE',
'pages/admin/scheduled-jobs/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE',
'pages/admin/user-lifecycle-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE',
'pages/admin/user-lifecycle-audit/view($id).php' => 'UiCapabilityMap::PAGE_USER_LIFECYCLE_VIEW',
];
foreach ($actions as $action => $mapConstant) {
$content = $this->readProjectFile($action);
$this->assertStringContainsString('->pageCapabilities(', $content, $action);
$this->assertStringContainsString($mapConstant, $content, $action);
}
}
public function testUiAccessServiceUsesCentralLayoutMapDefinition(): void
{
$content = $this->readProjectFile('lib/Service/Access/UiAccessService.php');
$this->assertStringContainsString('UiCapabilityMap::LAYOUT', $content);
}
public function testStatsPageCapabilityMapIncludesAuditCapabilities(): void
{
$content = $this->readProjectFile('lib/Service/Access/UiCapabilityMap.php');
$this->assertStringContainsString("'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW", $content);
$this->assertStringContainsString("'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW", $content);
}
public function testStatsTemplateDefinesAuditTabAndPanel(): void
{
$content = $this->readProjectFile('pages/admin/stats/index(default).phtml');
$this->assertStringContainsString('data-tab="audit"', $content);
$this->assertStringContainsString('data-tab-panel="audit"', $content);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class CodexSkillsContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testCoreGuardrailsSkillAndReferencesExist(): void
{
$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');
$this->readProjectFile('tools/codex-skills/core-guardrails/references/quality-gates.md');
$this->readProjectFile('tools/codex-skills/core-guardrails/references/source-map.md');
}
public function testStarterkitPlannerSkillContainsDecisionCompleteTemplateContract(): 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
{
$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
{
$index = $this->readProjectFile('docs/index.md');
$this->assertStringContainsString('/docs/codex-prompts.md', $index);
}
public function testCodexSkillsSyncScriptSupportsCheckAndApply(): void
{
$script = $this->readProjectFile('bin/codex-skills-sync.sh');
$this->assertStringContainsString('--check', $script);
$this->assertStringContainsString('--apply', $script);
$this->assertStringContainsString('mode="check"', $script);
$this->assertStringContainsString('core-guardrails', $script);
$this->assertStringContainsString('starterkit-planner', $script);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionMethod;
class ContainerFactoryContractTest extends TestCase
{
/**
* @return array<int, array{0: class-string}>
*/
public static function factoryClassProvider(): array
{
return [
[\MintyPHP\Service\Access\AccessServicesFactory::class],
[\MintyPHP\Service\AddressBook\AddressBookServicesFactory::class],
[\MintyPHP\Service\Audit\AuditServicesFactory::class],
[\MintyPHP\Service\Auth\AuthServicesFactory::class],
[\MintyPHP\Service\Branding\BrandingServicesFactory::class],
[\MintyPHP\Service\Directory\DirectoryServicesFactory::class],
[\MintyPHP\Service\Import\ImportServicesFactory::class],
[\MintyPHP\Service\Mail\MailServicesFactory::class],
[\MintyPHP\Service\Scheduler\SchedulerServicesFactory::class],
[\MintyPHP\Service\Security\SecurityServicesFactory::class],
[\MintyPHP\Service\Settings\SettingServicesFactory::class],
[\MintyPHP\Service\Tenant\TenantServicesFactory::class],
[\MintyPHP\Service\User\UserServicesFactory::class],
];
}
#[DataProvider('factoryClassProvider')]
public function testContainerFactoriesCachePublicNoArgCreateMethods(string $factoryClass): void
{
$factory = app($factoryClass);
$this->assertInstanceOf($factoryClass, $factory);
$reflection = new ReflectionClass($factoryClass);
$methods = array_filter(
$reflection->getMethods(ReflectionMethod::IS_PUBLIC),
static fn (ReflectionMethod $method): bool =>
!$method->isStatic()
&& str_starts_with($method->getName(), 'create')
&& $method->getNumberOfRequiredParameters() === 0
);
usort($methods, static fn (ReflectionMethod $a, ReflectionMethod $b): int => strcmp($a->getName(), $b->getName()));
foreach ($methods as $method) {
$methodName = $method->getName();
$first = $factory->{$methodName}();
$second = $factory->{$methodName}();
$this->assertIsObject($first, sprintf('%s::%s() must return an object', $factoryClass, $methodName));
$this->assertInstanceOf($first::class, $second, sprintf('%s::%s() must be type-stable', $factoryClass, $methodName));
$this->assertSame($first, $second, sprintf('%s::%s() must return the cached instance', $factoryClass, $methodName));
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class CoreStarterkitContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testPagesDoNotInstantiateServicesRepositoriesOrGatewaysDirectly(): void
{
$violations = $this->findPatternMatchesInPhpFiles('pages', '/\bnew\s+[\\\\A-Za-z0-9_]+(?:Service|Gateway|Repository)\s*\(/');
$this->assertSame([], $violations, "Direct service/repository/gateway instantiation found in pages/:\n" . implode("\n", $violations));
}
public function testPagesDoNotUseDatabaseFacadeDirectly(): void
{
$violations = $this->findPatternMatchesInPhpFiles('pages', '/\bDB::/');
$this->assertSame([], $violations, "Direct DB facade usage found in pages/:\n" . implode("\n", $violations));
}
public function testAdminPagesDoNotUseFactoryCreateChains(): void
{
$violations = $this->findPatternMatchesInPhpFiles('pages/admin', '/app\([^\n]*Factory::class\)->create/');
$this->assertSame([], $violations, "Factory create-chain usage found in pages/admin/:\n" . implode("\n", $violations));
}
public function testAdminPagesDoNotReferenceFactoryClasses(): void
{
$violations = $this->findPatternMatchesInPhpFiles('pages/admin', '/Factory::class/');
$this->assertSame([], $violations, "Factory class usage found in pages/admin/:\n" . implode("\n", $violations));
}
public function testPagesUseRequestInputInsteadOfSuperglobals(): void
{
$patterns = [
'/\$_POST/' => '$_POST',
'/\$_GET/' => '$_GET',
'/\$_FILES/' => '$_FILES',
'/REQUEST_METHOD/' => 'REQUEST_METHOD',
];
foreach ($patterns as $pattern => $label) {
$violations = $this->findPatternMatchesInPhpFiles('pages', $pattern);
$this->assertSame([], $violations, "Superglobal {$label} usage found in pages/:\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));
}
/**
* @return list<string>
*/
private function findPatternMatchesInPhpFiles(string $relativeDirectory, string $pattern): array
{
$root = $this->projectRootPath();
$basePath = $root . '/' . $relativeDirectory;
$this->assertDirectoryExists($basePath, 'Directory not found: ' . $relativeDirectory);
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basePath));
$violations = [];
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$content = file_get_contents($file->getPathname());
$this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname());
if (!preg_match($pattern, $content)) {
continue;
}
$relativePath = str_replace($root . '/', '', $file->getPathname());
$violations[] = $relativePath;
}
sort($violations);
return $violations;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class DetailActionPolicyContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function migratedConfirmFiles(): array
{
return [
'pages/admin/users/edit(default).phtml',
'pages/admin/users/_form.phtml',
'pages/admin/tenants/edit(default).phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/departments/edit(default).phtml',
'pages/admin/roles/edit(default).phtml',
'pages/admin/permissions/edit(default).phtml',
'pages/admin/settings/index(default).phtml',
'templates/partials/app-details-titlebar.phtml',
'templates/partials/app-details-aside-actions.phtml',
];
}
public function testDetailPageFactoryUsesActionPolicyModule(): void
{
$content = $this->readProjectFile('web/js/core/app-detail-page-factory.js');
$this->assertStringContainsString("import { initDetailActionPolicy } from './app-details-action-policy.js';", $content);
$this->assertStringContainsString('const actionPolicy = initDetailActionPolicy(', $content);
$this->assertStringContainsString('actionPolicy.isSubmitting()', $content);
}
public function testDetailsPartialsExposeActionPolicyContract(): void
{
$titlebar = $this->readProjectFile('templates/partials/app-details-titlebar.phtml');
$this->assertStringContainsString('data-detail-action-policy="1"', $titlebar);
$this->assertStringContainsString('data-detail-action-kind=', $titlebar);
$this->assertStringContainsString('data-detail-confirm-message=', $titlebar);
$asideActions = $this->readProjectFile('templates/partials/app-details-aside-actions.phtml');
$this->assertStringContainsString('data-detail-confirm-message=', $asideActions);
$danger = $this->readProjectFile('templates/partials/app-danger-zone-delete-field.phtml');
$this->assertStringContainsString('data-detail-action-kind="delete"', $danger);
}
public function testMigratedFilesNoLongerUseInlineConfirmHandlers(): void
{
foreach ($this->migratedConfirmFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('onsubmit="return confirm(', $content, $file);
$this->assertStringNotContainsString('onclick="return confirm(', $content, $file);
}
}
public function testMigratedFilesExposeDataDetailConfirmContract(): void
{
foreach ($this->migratedConfirmFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-detail-confirm-message', $content, $file);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class DetailPageContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function standardDetailFormFiles(): array
{
return [
'pages/admin/users/_form.phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/departments/_form.phtml',
'pages/admin/roles/_form.phtml',
'pages/admin/permissions/_form.phtml',
'pages/admin/scheduled-jobs/edit(default).phtml',
'pages/admin/settings/index(default).phtml',
];
}
public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void
{
foreach ($this->standardDetailFormFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-standard-detail-form="1"', $content, $file);
}
}
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString("import './components/app-standard-detail-page.js';", $content);
}
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
{
$content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml');
$this->assertStringContainsString('data-detail-unsaved-message=', $content);
$this->assertStringContainsString('data-detail-action-policy="1"', $content);
$this->assertStringContainsString('data-detail-action-kind=', $content);
$this->assertStringContainsString('data-detail-save-primary="1"', $content);
}
public function testImportsWizardFormsUseStandardDetailOptIn(): void
{
$content = $this->readProjectFile('pages/admin/imports/index(default).phtml');
$this->assertStringContainsString('id="imports-upload-form"', $content);
$this->assertStringContainsString('id="imports-mapping-form"', $content);
$this->assertStringContainsString('id="imports-commit-form"', $content);
$this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"'));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class DetailValidationSummaryContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function detailTemplateFiles(): array
{
return [
'pages/admin/users/create(default).phtml',
'pages/admin/users/edit(default).phtml',
'pages/admin/tenants/create(default).phtml',
'pages/admin/tenants/edit(default).phtml',
'pages/admin/departments/create(default).phtml',
'pages/admin/departments/edit(default).phtml',
'pages/admin/roles/create(default).phtml',
'pages/admin/roles/edit(default).phtml',
'pages/admin/permissions/create(default).phtml',
'pages/admin/permissions/edit(default).phtml',
'pages/admin/scheduled-jobs/edit(default).phtml',
];
}
/**
* @return list<string>
*/
private function detailActionFiles(): array
{
return [
'pages/admin/users/create().php',
'pages/admin/users/edit($id).php',
'pages/admin/tenants/create().php',
'pages/admin/tenants/edit($id).php',
'pages/admin/departments/create().php',
'pages/admin/departments/edit($id).php',
'pages/admin/roles/create().php',
'pages/admin/roles/edit($id).php',
'pages/admin/permissions/create().php',
'pages/admin/permissions/edit($id).php',
'pages/admin/scheduled-jobs/edit($id).php',
];
}
public function testScopedDetailTemplatesUseSharedValidationSummaryPartial(): void
{
foreach ($this->detailTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("require templatePath('partials/app-details-validation-summary.phtml');", $content, $file);
}
}
public function testScopedDetailTemplatesDoNotInlineLegacyErrorLoop(): void
{
foreach ($this->detailTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertSame(0, preg_match('/foreach\s*\(\$errors\s+as\s+\$error\)/', $content), $file);
}
}
public function testSharedPartialExposesValidationSummaryContract(): void
{
$content = $this->readProjectFile('templates/partials/app-details-validation-summary.phtml');
$this->assertStringContainsString('data-validation-summary', $content);
$this->assertStringContainsString('data-validation-summary-autofocus=', $content);
$this->assertStringContainsString('data-validation-error-link', $content);
$this->assertStringNotContainsString('class="notice"', $content);
}
public function testScopedDetailActionsProvideStructuredValidationSummaryErrors(): void
{
foreach ($this->detailActionFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('$validationSummaryErrors = $errorBag->toArray();', $content, $file);
}
}
public function testDefaultStyleGroupIncludesValidationSummaryComponentCss(): void
{
$content = $this->readProjectFile('config/assets.php');
$this->assertStringContainsString("'css/components/app-details-validation-summary.css'", $content);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class FilterDrawerContractTest extends TestCase
{
use ProjectFileAssertionSupport;
private function usesSharedListFiltersPartial(string $content): bool
{
return str_contains($content, "require templatePath('partials/app-list-filters.phtml');");
}
/**
* @return list<string>
*/
private function drawerTemplateFiles(): array
{
return [
'pages/address-book/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',
'pages/admin/roles/index(default).phtml',
'pages/admin/permissions/index(default).phtml',
'pages/admin/mail-log/index(default).phtml',
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
];
}
public function testDrawerTemplatesUseStandardListWrapperForFilterExperience(): void
{
foreach ($this->drawerTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertStringContainsString('initStandardListPage(', $content, $file);
$this->assertStringContainsString("mode: 'drawer'", $content, $file);
$this->assertStringNotContainsString('initListFilterExperience(', $content, $file);
if (!$usesPartial) {
$this->assertStringContainsString('role="dialog"', $content, $file);
$this->assertStringContainsString('aria-modal="true"', $content, $file);
$this->assertStringContainsString('aria-labelledby="', $content, $file);
$this->assertStringContainsString('data-filter-live-region', $content, $file);
}
$this->assertStringNotContainsString('data-filter-drawer-draft-hint', $content, $file);
}
}
public function testAddressBookSaveFilterReadsAppliedGridState(): void
{
$content = $this->readProjectFile('pages/address-book/index(default).phtml');
$this->assertStringContainsString('new URL(gridConfig.baseUrl()).searchParams', $content);
$this->assertStringNotContainsString('const state = collectFilterState();', $content);
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
$this->assertStringContainsString('data-filter-chips-remove-label', $content);
$this->assertStringContainsString('data-filter-live-applied-message', $content);
$this->assertStringContainsString('data-filter-live-removed-message', $content);
$this->assertStringContainsString('data-filter-live-cleared-message', $content);
}
public function testUsersResetKeepsTenantParam(): void
{
$content = $this->readProjectFile('pages/admin/users/index(default).phtml');
$this->assertStringContainsString("preserveFilterParams: ['tenant']", $content);
}
public function testFilterDrawerAndExperienceImplementFocusTrapAndDirtyApplyUi(): void
{
$drawerJs = $this->readProjectFile('web/js/components/app-filter-drawer.js');
$this->assertStringContainsString("drawer.setAttribute('role', 'dialog')", $drawerJs);
$this->assertStringContainsString("drawer.setAttribute('aria-modal', 'true')", $drawerJs);
$this->assertStringContainsString("if (event.key !== 'Tab') {return;}", $drawerJs);
$this->assertStringContainsString('if (lastTrigger && typeof lastTrigger.focus === \'function\'', $drawerJs);
$experienceJs = $this->readProjectFile('web/js/pages/app-list-filter-experience.js');
$this->assertStringContainsString('countDraftChanges(', $experienceJs);
$this->assertStringContainsString('countActiveDraftFilters(', $experienceJs);
$this->assertStringContainsString('drawerController.setApplyEnabled(changedCount > 0);', $experienceJs);
$this->assertStringContainsString('drawerController.setApplyCount(activeCount);', $experienceJs);
$this->assertStringContainsString('announceLive(', $experienceJs);
$stateJs = $this->readProjectFile('web/js/pages/app-list-filter-state.js');
$this->assertStringContainsString('buildChipsFromMeta', $stateJs);
$this->assertStringContainsString('removeChipFromMetaState', $stateJs);
$this->assertStringContainsString('clearMetaState', $stateJs);
}
public function testSharedListFiltersPartialKeepsDrawerAccessibilityContract(): void
{
$content = $this->readProjectFile('templates/partials/app-list-filters.phtml');
$this->assertStringContainsString('renderGridFilterToolbar(', $content);
$this->assertStringContainsString('data-filter-drawer-open', $content);
$this->assertStringContainsString('data-filter-drawer-apply', $content);
$this->assertStringContainsString('data-active-filter-chips', $content);
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
$this->assertStringContainsString('data-filter-chips-remove-label', $content);
$this->assertStringContainsString('role="dialog"', $content);
$this->assertStringContainsString('aria-modal="true"', $content);
$this->assertStringContainsString('aria-labelledby=', $content);
$this->assertStringContainsString('data-filter-live-region', $content);
$this->assertStringContainsString('data-filter-live-applied-message', $content);
$this->assertStringContainsString('data-filter-live-removed-message', $content);
$this->assertStringContainsString('data-filter-live-cleared-message', $content);
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class ListFilterContractTest extends TestCase
{
use ProjectFileAssertionSupport;
private function usesSharedListFiltersPartial(string $content): bool
{
return str_contains($content, "require templatePath('partials/app-list-filters.phtml');");
}
/**
* @return list<string>
*/
private function hardCutGridEndpointFiles(): array
{
return [
'pages/address-book/data().php',
'pages/search/data().php',
'pages/admin/users/data().php',
'pages/admin/tenants/data().php',
'pages/admin/departments/data().php',
'pages/admin/roles/data().php',
'pages/admin/permissions/data().php',
'pages/admin/mail-log/data().php',
'pages/admin/scheduled-jobs/data().php',
'pages/admin/scheduled-jobs/runs-data($id).php',
'pages/admin/api-audit/data().php',
'pages/admin/import-audit/data().php',
'pages/admin/user-lifecycle-audit/data().php',
'pages/admin/system-audit/data().php',
];
}
/**
* @return array<string,string>
*/
private function hardCutGridEndpointSchemaFiles(): array
{
return [
'pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/departments/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/roles/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/permissions/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/mail-log/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/scheduled-jobs/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/scheduled-jobs/runs-data($id).php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/runs-filter-schema.php'",
'pages/admin/api-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/import-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/user-lifecycle-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/system-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
];
}
/**
* @return list<string>
*/
private function listIndexTemplates(): array
{
return [
'pages/address-book/index(default).phtml',
'pages/search/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',
'pages/admin/roles/index(default).phtml',
'pages/admin/permissions/index(default).phtml',
'pages/admin/mail-log/index(default).phtml',
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
];
}
/**
* @return list<string>
*/
private function listIndexActions(): array
{
return [
'pages/address-book/index().php',
'pages/search/index().php',
'pages/admin/users/index().php',
'pages/admin/tenants/index().php',
'pages/admin/departments/index().php',
'pages/admin/roles/index().php',
'pages/admin/permissions/index().php',
'pages/admin/mail-log/index().php',
'pages/admin/scheduled-jobs/index().php',
'pages/admin/api-audit/index().php',
'pages/admin/import-audit/index().php',
'pages/admin/user-lifecycle-audit/index().php',
'pages/admin/system-audit/index().php',
];
}
/**
* @return list<string>
*/
private function drawerListTemplates(): array
{
return [
'pages/address-book/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',
'pages/admin/roles/index(default).phtml',
'pages/admin/permissions/index(default).phtml',
'pages/admin/mail-log/index(default).phtml',
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
];
}
/**
* @return list<string>
*/
private function searchOnlyTemplates(): array
{
return [
'pages/search/index(default).phtml',
];
}
public function testDataEndpointsRequireGetGuard(): void
{
$files = $this->hardCutGridEndpointFiles();
$this->assertCount(14, $files, 'Unexpected number of hard-cut grid data endpoints.');
$this->assertNotContains('pages/admin/search/data().php', $files);
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('gridRequireGetRequest();', $content, $file);
$this->assertStringNotContainsString("strtoupper((string) (requestInput()->method())) !== 'GET'", $content, $file);
}
}
public function testDataEndpointsDoNotInlineQueryAllIndexParsing(): void
{
foreach ($this->hardCutGridEndpointFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString("requestInput()->queryAll()['", $content, $file);
$this->assertStringContainsString('gridParseFiltersFromSchemaFile', $content, $file);
}
}
public function testDataEndpointsDoNotUseLegacySingleIdAliases(): void
{
foreach ($this->hardCutGridEndpointFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertSame(
0,
preg_match('/\?\?\s*\([^\n]*\[[\"\"][a-z_]+_id[\"\"]\]/', $content),
$file
);
}
}
public function testAuditRepositoriesDoNotUseLegacySingleIdAliasFallbacks(): void
{
$files = [
'lib/Repository/Audit/ApiAuditLogRepository.php',
'lib/Repository/Audit/ImportAuditRunRepository.php',
'lib/Repository/Audit/UserLifecycleAuditRepository.php',
'lib/Repository/Audit/SystemAuditLogRepository.php',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertSame(
0,
preg_match('/\[[\"\"][a-z_]+_ids[\"\"]\]\s*\?\?\s*\(\$filters\[[\"\"][a-z_]+_id[\"\"]\]/', $content),
$file
);
}
}
public function testDataEndpointsUseDirectoryFilterSchema(): void
{
foreach ($this->hardCutGridEndpointSchemaFiles() as $file => $expectedHelperCall) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString($expectedHelperCall, $content, $file);
$schemaFile = str_contains($expectedHelperCall, '/runs-filter-schema.php')
? dirname($file) . '/runs-filter-schema.php'
: dirname($file) . '/filter-schema.php';
$schemaContent = $this->readProjectFile($schemaFile);
$this->assertStringContainsString('gridFilterSchema', $schemaContent, $schemaFile);
}
}
public function testDataEndpointsUseUnifiedGuardAbilityPatternExceptSearchResults(): void
{
foreach ($this->hardCutGridEndpointFiles() as $file) {
$content = $this->readProjectFile($file);
if ($file === 'pages/search/data().php') {
$this->assertStringContainsString('Guard::requireLogin();', $content, $file);
$this->assertStringNotContainsString('Guard::requireAbility', $content, $file);
continue;
}
$this->assertMatchesRegularExpression(
'/Guard::requireAbility(?:OrForbidden|DecisionOrForbidden)\(/',
$content,
$file
);
$this->assertStringNotContainsString('->authorize(', $content, $file);
}
}
public function testDataEndpointsUseUnifiedDataAndTotalResponseHelper(): void
{
foreach ($this->hardCutGridEndpointFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('gridJsonDataResult(', $content, $file);
$this->assertStringNotContainsString('Router::json($result);', $content, $file);
}
}
public function testListTemplatesUseCentralToolbarRendererAndStandardListWrapper(): void
{
foreach ($this->listIndexTemplates() as $file) {
$content = $this->readProjectFile($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertTrue(
$usesPartial || str_contains($content, 'renderGridFilterToolbar('),
$file . ' does not render toolbar directly and does not include shared list-filters partial.'
);
$this->assertStringContainsString('initStandardListPage(', $content, $file);
$this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file);
$this->assertStringNotContainsString('data-toolbar-toggle', $content, $file);
$this->assertStringNotContainsString('data-filter-overflow', $content, $file);
$this->assertStringNotContainsString('data-filter-toggle', $content, $file);
$this->assertSame(0, preg_match('/<select id=\"[^\"]*-filter\"/', $content), $file);
$this->assertSame(0, preg_match('/<input[^>]*id=\"[^\"]*-filter\"/', $content), $file);
$this->assertSame(0, preg_match('/filters:\s*\[/', $content), $file);
}
}
public function testListActionsExposeSplitToolbarSchemasAndChipMeta(): void
{
foreach ($this->listIndexActions() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('$searchToolbarFilterSchema', $content, $file);
$this->assertStringContainsString('$drawerToolbarFilterSchema', $content, $file);
$this->assertStringContainsString('$filterChipMeta', $content, $file);
}
}
public function testDrawerListTemplatesUseDrawerAndActiveFilterChips(): void
{
foreach ($this->drawerListTemplates() as $file) {
$content = $this->readProjectFile($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertStringContainsString("mode: 'drawer'", $content, $file);
if (!$usesPartial) {
$this->assertStringContainsString('data-filter-drawer-open', $content, $file);
$this->assertStringContainsString('data-filter-drawer-apply', $content, $file);
$this->assertStringContainsString('data-active-filter-chips', $content, $file);
}
$this->assertStringNotContainsString('data-filter-drawer-reset', $content, $file);
}
}
public function testSearchOnlyTemplatesDoNotUseDrawerControls(): void
{
foreach ($this->searchOnlyTemplates() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("mode: 'search-only'", $content, $file);
$this->assertStringNotContainsString('data-filter-drawer-open', $content, $file);
$this->assertStringNotContainsString('data-filter-drawer-apply', $content, $file);
$this->assertStringNotContainsString('data-active-filter-chips', $content, $file);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class ListTitlebarContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function titlebarTemplateFiles(): array
{
return [
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',
'pages/admin/roles/index(default).phtml',
'pages/admin/permissions/index(default).phtml',
'pages/admin/mail-log/index(default).phtml',
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
'pages/address-book/index(default).phtml',
'pages/search/index(default).phtml',
'pages/help/hotkeys(default).phtml',
];
}
public function testTemplatesUseSharedListTitlebarPartial(): void
{
foreach ($this->titlebarTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("require templatePath('partials/app-list-titlebar.phtml');", $content, $file);
}
}
public function testTemplatesDoNotInlineLegacyListTitlebarMarkup(): void
{
foreach ($this->titlebarTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertSame(0, preg_match('/<div\s+class=\"app-list-titlebar\"/', $content), $file);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class ListUiSharedPartialsContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function listTabsTemplateFiles(): array
{
return [
'pages/admin/users/index(default).phtml',
'pages/admin/departments/index(default).phtml',
];
}
/**
* @return list<string>
*/
private function purgeTitlebarTemplateFiles(): array
{
return [
'pages/admin/scheduled-jobs/index(default).phtml',
'pages/admin/api-audit/index(default).phtml',
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
];
}
public function testTabsTemplatesUseSharedTabsPartial(): void
{
foreach ($this->listTabsTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("require templatePath('partials/app-list-tabs.phtml');", $content, $file);
$this->assertSame(0, preg_match('/<div\s+class=\"app-list-tabs\"/', $content), $file);
}
}
public function testPurgeTitlebarTemplatesUseSharedPurgePartial(): void
{
foreach ($this->purgeTitlebarTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("require templatePath('partials/app-list-purge-action.phtml');", $content, $file);
$this->assertSame(0, preg_match('/<form\s+id=\"[^\"]*purge-form\"/', $content), $file);
$this->assertStringNotContainsString('onclick="return confirm(\'', $content, $file);
}
}
public function testTabsAndPurgePartialsExposeExpectedMarkupContract(): void
{
$tabsPartial = $this->readProjectFile('templates/partials/app-list-tabs.phtml');
$this->assertStringContainsString('class="app-list-tabs"', $tabsPartial);
$this->assertStringContainsString("class=\"<?php e(\$tabActive ? 'tab-link is-active' : 'tab-link'); ?>\"", $tabsPartial);
$purgePartial = $this->readProjectFile('templates/partials/app-list-purge-action.phtml');
$this->assertStringContainsString('method="post"', $purgePartial);
$this->assertStringContainsString('Session::getCsrfInput();', $purgePartial);
$this->assertStringContainsString('data-confirm-message="<?php e($listPurgeConfirmMessage); ?>"', $purgePartial);
$this->assertStringNotContainsString('onclick="return confirm(\'', $purgePartial);
}
public function testAppInitIncludesGlobalConfirmActionComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString("import './components/app-confirm-actions.js';", $content);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Tests\Architecture;
trait ProjectFileAssertionSupport
{
private function projectRootPath(): string
{
$root = realpath(__DIR__ . '/../..');
$this->assertNotFalse($root, 'Project root not found.');
return $root;
}
private function readProjectFile(string $path): string
{
$root = $this->projectRootPath();
$fullPath = $root . '/' . $path;
$this->assertFileExists($fullPath, 'File not found: ' . $path);
$content = file_get_contents($fullPath);
$this->assertNotFalse($content, 'Could not read file: ' . $path);
return $content;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class StatusTaxonomyContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function taxonomyEnumFiles(): array
{
return [
'lib/Domain/Taxonomy/SystemAuditOutcome.php',
'lib/Domain/Taxonomy/SystemAuditChannel.php',
'lib/Domain/Taxonomy/UserLifecycleAction.php',
'lib/Domain/Taxonomy/UserLifecycleTriggerType.php',
'lib/Domain/Taxonomy/UserLifecycleStatus.php',
'lib/Domain/Taxonomy/ImportAuditStatus.php',
'lib/Domain/Taxonomy/ScheduledJobStatus.php',
'lib/Domain/Taxonomy/ScheduledJobRunStatus.php',
'lib/Domain/Taxonomy/ScheduledJobTriggerType.php',
'lib/Domain/Taxonomy/MailLogStatus.php',
'lib/Domain/Taxonomy/TenantStatus.php',
'lib/Domain/Taxonomy/SchedulerRuntimeResult.php',
];
}
/**
* @return array<string,list<string>>
*/
private function taxonomySchemaExpectations(): array
{
return [
'pages/admin/system-audit/filter-schema.php' => [
'SystemAuditOutcome::values()',
'SystemAuditChannel::values()',
],
'pages/admin/user-lifecycle-audit/filter-schema.php' => [
'gridEnumSanitizer(UserLifecycleAction::class)',
'gridEnumSanitizer(UserLifecycleStatus::class)',
'gridEnumSanitizer(UserLifecycleTriggerType::class)',
],
'pages/admin/import-audit/filter-schema.php' => [
'ImportAuditStatus::values()',
],
'pages/admin/scheduled-jobs/filter-schema.php' => [
'ScheduledJobStatus::values()',
],
'pages/admin/scheduled-jobs/runs-filter-schema.php' => [
'ScheduledJobRunStatus::values()',
'ScheduledJobTriggerType::values()',
],
'pages/admin/mail-log/filter-schema.php' => [
'MailLogStatus::values()',
],
];
}
/**
* @return list<string>
*/
private function taxonomyDataEndpointFiles(): array
{
return [
'pages/admin/system-audit/data().php',
'pages/admin/user-lifecycle-audit/data().php',
'pages/admin/import-audit/data().php',
'pages/admin/scheduled-jobs/data().php',
'pages/admin/scheduled-jobs/runs-data($id).php',
'pages/admin/mail-log/data().php',
'pages/admin/tenants/data().php',
];
}
/**
* @return list<string>
*/
private function taxonomyLiteralGuardFiles(): array
{
return [
'lib/Service/Audit/SystemAuditService.php',
'lib/Repository/Audit/SystemAuditLogRepository.php',
'lib/Http/ApiSystemAuditReporter.php',
'lib/Service/Audit/UserLifecycleAuditService.php',
'lib/Repository/Audit/UserLifecycleAuditRepository.php',
'lib/Service/Audit/ImportAuditService.php',
'lib/Repository/Audit/ImportAuditRunRepository.php',
'lib/Service/Scheduler/SchedulerRunService.php',
'lib/Repository/Scheduler/ScheduledJobRepository.php',
'lib/Repository/Scheduler/ScheduledJobRunRepository.php',
'lib/Repository/Scheduler/SchedulerRuntimeRepository.php',
'lib/Service/Mail/MailService.php',
'lib/Repository/Mail/MailLogRepository.php',
'lib/Service/Tenant/TenantService.php',
'lib/Repository/Tenant/TenantRepository.php',
'lib/Repository/Stats/AdminStatsRepository.php',
'pages/admin/system-audit/data().php',
'pages/admin/user-lifecycle-audit/data().php',
'pages/admin/import-audit/data().php',
'pages/admin/scheduled-jobs/data().php',
'pages/admin/scheduled-jobs/runs-data($id).php',
'pages/admin/mail-log/data().php',
'pages/admin/tenants/data().php',
'pages/admin/system-audit/index().php',
'pages/admin/user-lifecycle-audit/index().php',
'pages/admin/import-audit/index().php',
'pages/admin/scheduled-jobs/index().php',
'pages/admin/mail-log/index().php',
];
}
public function testTaxonomyEnumFilesUseSharedTraitAndRequiredMethods(): void
{
foreach ($this->taxonomyEnumFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('enum ', $content, $file);
$this->assertStringContainsString('use SupportsStringTaxonomy;', $content, $file);
$this->assertStringContainsString('function labelToken()', $content, $file);
$this->assertStringContainsString('function badgeVariant()', $content, $file);
}
}
public function testFilterSchemasReadTaxonomyValuesFromEnums(): void
{
foreach ($this->taxonomySchemaExpectations() as $file => $snippets) {
$content = $this->readProjectFile($file);
foreach ($snippets as $snippet) {
$this->assertStringContainsString($snippet, $content, $file);
}
}
}
public function testDataEndpointsUseEnumBasedBadgeAndLabelResolution(): void
{
foreach ($this->taxonomyDataEndpointFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('->badgeVariant()', $content, $file);
$this->assertStringContainsString('->labelToken()', $content, $file);
}
}
public function testNoLegacyLiteralTaxonomyChecksRemainInMigratedFiles(): void
{
$forbiddenPatterns = [
"/\\bstatus\\s*=\\s*'(running|success|partial|failed|skipped|queued|sent|active|inactive)'/",
"/\\boutcome\\s*=\\s*'(success|failed|denied)'/",
"/\\bchannel\\s*=\\s*'(web|api|scheduler|cli)'/",
"/\\btrigger_type\\s*=\\s*'(scheduler|manual|cron|system)'/",
"/\\baction\\s*=\\s*'(deactivate|delete|restore)'/",
"/['\\\"]statuses['\\\"]\\s*=>\\s*'failed,skipped'/",
"/['\\\"]actions['\\\"]\\s*=>\\s*'restore'/",
"/in_array\\([^\\n]*\\[(?:[^\\]]*'(?:success|failed|denied|running|partial|skipped|queued|sent|active|inactive|scheduler|manual|web|api|cli|deactivate|delete|restore|ok|lock_not_acquired|unexpected_error)')/",
];
foreach ($this->taxonomyLiteralGuardFiles() as $file) {
$content = $this->readProjectFile($file);
foreach ($forbiddenPatterns as $pattern) {
$this->assertSame(0, preg_match($pattern, $content), $file . ' pattern=' . $pattern);
}
}
}
public function testImportAuditIndexTemplateDoesNotUseLegacyStatusMapper(): void
{
$content = $this->readProjectFile('pages/admin/import-audit/index(default).phtml');
$this->assertStringNotContainsString('const statusLabel = (value) =>', $content);
$this->assertStringNotContainsString("if (key === 'running')", $content);
$this->assertStringContainsString('cell.label || \'-\'', $content);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Tests\Domain\Taxonomy;
use MintyPHP\Domain\Taxonomy\ImportAuditStatus;
use MintyPHP\Domain\Taxonomy\MailLogStatus;
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
use MintyPHP\Domain\Taxonomy\ScheduledJobStatus;
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Domain\Taxonomy\TenantStatus;
use MintyPHP\Domain\Taxonomy\UserLifecycleAction;
use MintyPHP\Domain\Taxonomy\UserLifecycleStatus;
use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class TaxonomyEnumContractTest extends TestCase
{
/**
* @return iterable<string, array{0: class-string<\BackedEnum>, 1: list<string>}>
*/
public static function enumProvider(): iterable
{
yield 'SystemAuditOutcome' => [SystemAuditOutcome::class, ['success', 'failed', 'denied']];
yield 'SystemAuditChannel' => [SystemAuditChannel::class, ['web', 'api', 'scheduler', 'cli']];
yield 'UserLifecycleAction' => [UserLifecycleAction::class, ['deactivate', 'delete', 'restore']];
yield 'UserLifecycleTriggerType' => [UserLifecycleTriggerType::class, ['manual', 'cron', 'system']];
yield 'UserLifecycleStatus' => [UserLifecycleStatus::class, ['success', 'skipped', 'failed']];
yield 'ImportAuditStatus' => [ImportAuditStatus::class, ['running', 'success', 'partial', 'failed']];
yield 'ScheduledJobStatus' => [ScheduledJobStatus::class, ['running', 'success', 'failed', 'skipped']];
yield 'ScheduledJobRunStatus' => [ScheduledJobRunStatus::class, ['success', 'failed', 'skipped']];
yield 'ScheduledJobTriggerType' => [ScheduledJobTriggerType::class, ['scheduler', 'manual']];
yield 'MailLogStatus' => [MailLogStatus::class, ['queued', 'sent', 'failed']];
yield 'TenantStatus' => [TenantStatus::class, ['active', 'inactive']];
yield 'SchedulerRuntimeResult' => [SchedulerRuntimeResult::class, ['ok', 'lock_not_acquired', 'unexpected_error']];
}
#[DataProvider('enumProvider')]
public function testValuesReturnExpectedList(string $enumClass, array $expectedValues): void
{
$this->assertSame($expectedValues, $enumClass::values());
}
#[DataProvider('enumProvider')]
public function testTryNormalizeAndNormalizeOr(string $enumClass, array $expectedValues): void
{
$firstValue = $expectedValues[0];
$firstCase = $enumClass::from($firstValue);
$this->assertSame($firstValue, $enumClass::tryNormalize($firstValue)?->value);
$this->assertSame($firstValue, $enumClass::tryNormalize(strtoupper($firstValue))?->value);
$this->assertSame($firstValue, $enumClass::tryNormalize(' ' . strtoupper($firstValue) . ' ')?->value);
$this->assertNull($enumClass::tryNormalize(''));
$this->assertNull($enumClass::tryNormalize('not-valid'));
$this->assertSame($firstValue, $enumClass::normalizeOr('not-valid', $firstCase)->value);
$this->assertSame($firstValue, $enumClass::normalizeOr($firstValue, $firstCase)->value);
}
#[DataProvider('enumProvider')]
public function testBadgeVariantAndLabelTokenAreDefined(string $enumClass, array $expectedValues): void
{
foreach ($expectedValues as $value) {
$case = $enumClass::from($value);
$labelToken = $case->labelToken();
$badgeVariant = $case->badgeVariant();
$this->assertIsString($labelToken);
$this->assertNotSame('', trim($labelToken));
$this->assertIsString($badgeVariant);
$this->assertNotSame('', trim($badgeVariant));
}
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\ApiSystemAuditReporter;
use MintyPHP\Http\RequestContext;
use MintyPHP\Service\Audit\SystemAuditService;
use PHPUnit\Framework\TestCase;
class ApiSystemAuditReporterTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$_SERVER = [];
RequestContext::resetForTests();
ApiAuth::authenticate();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
ApiAuth::authenticate();
}
public function testItRecordsMutatingRequest(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/api/v1/users/123';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['endpoint_key'] ?? '') === '/api/v1/users/{id}'
&& ($metadata['status_code'] ?? null) === 201
&& ($metadata['status_class'] ?? '') === '2xx'
&& ($metadata['auth_mode'] ?? '') === 'public'
&& ($metadata['write_method'] ?? null) === true
&& ($metadata['security_endpoint'] ?? null) === false;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(201);
}
public function testItSkipsNonSecurityGetSuccess(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/users';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(200);
}
public function testItRecordsDeniedSecurityGetFailure(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/me/tokens';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'denied',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['endpoint_key'] ?? '') === '/api/v1/me/tokens'
&& ($metadata['status_code'] ?? null) === 401
&& ($metadata['status_class'] ?? '') === '4xx'
&& ($metadata['security_endpoint'] ?? null) === true;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(401, 'unauthorized');
}
public function testItRecordsFailedServerError(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/users';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'failed',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['status_code'] ?? null) === 500
&& ($metadata['status_class'] ?? '') === '5xx'
&& ($metadata['write_method'] ?? null) === false;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(500, 'unexpected_error');
}
public function testFinishIsIdempotent(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/api/v1/tenants';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record');
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(201);
$reporter->finish(201);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
class RequestContextTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
}
public function testItGeneratesStableRequestIdPerRequest(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/admin/users?x=1';
RequestContext::start();
$first = RequestContext::id();
$second = RequestContext::id();
$this->assertSame($first, $second);
$this->assertMatchesRegularExpression('/^[a-f0-9-]{36}$/', $first);
}
public function testItAppliesOverridesForChannelAndPath(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/settings';
RequestContext::start([
'channel' => 'scheduler',
'path' => '/bin/scheduler-run.php',
]);
$context = RequestContext::context();
$this->assertSame('scheduler', $context['channel']);
$this->assertSame('/bin/scheduler-run.php', $context['path']);
$this->assertSame('POST', $context['method']);
}
}

View File

@@ -80,4 +80,16 @@ class RepoQueryTest extends TestCase
$this->assertSame(['foo = ?'], $where);
$this->assertSame(['bar'], $params);
}
public function testNormalizeStringList(): void
{
$list = RepoQuery::normalizeStringList(' a, b, a ,, c ');
$this->assertSame(['a', 'b', 'c'], $list);
$list = RepoQuery::normalizeStringList([' A ', ['B', 'a'], ''], 10, static fn (string $value): string => strtolower($value));
$this->assertSame(['a', 'b'], $list);
$list = RepoQuery::normalizeStringList('1,2,3,4', 2);
$this->assertSame(['1', '2'], $list);
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Repository\Access\PermissionRepository;
use MintyPHP\Repository\Access\RolePermissionRepository;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
use MintyPHP\Service\Access\PermissionAuthorizationPolicy;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Access\RoleAuthorizationPolicy;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use PHPUnit\Framework\TestCase;
class AccessServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new AccessServicesFactory();
$this->assertInstanceOf(PermissionRepository::class, $factory->createPermissionRepository());
$this->assertSame($factory->createPermissionRepository(), $factory->createPermissionRepository());
$this->assertInstanceOf(RolePermissionRepository::class, $factory->createRolePermissionRepository());
$this->assertSame($factory->createRolePermissionRepository(), $factory->createRolePermissionRepository());
$this->assertInstanceOf(UserRoleRepository::class, $factory->createUserRoleRepository());
$this->assertSame($factory->createUserRoleRepository(), $factory->createUserRoleRepository());
$this->assertInstanceOf('MintyPHP\\Service\\Access\\PermissionService', $factory->createPermissionService());
$this->assertSame($factory->createPermissionService(), $factory->createPermissionService());
$this->assertInstanceOf(PermissionGateway::class, $factory->createPermissionGateway());
$this->assertSame($factory->createPermissionGateway(), $factory->createPermissionGateway());
$this->assertInstanceOf(UserAuthorizationPolicy::class, $factory->createUserAuthorizationPolicy());
$this->assertSame($factory->createUserAuthorizationPolicy(), $factory->createUserAuthorizationPolicy());
$this->assertInstanceOf(RoleAuthorizationPolicy::class, $factory->createRoleAuthorizationPolicy());
$this->assertSame($factory->createRoleAuthorizationPolicy(), $factory->createRoleAuthorizationPolicy());
$this->assertInstanceOf(PermissionAuthorizationPolicy::class, $factory->createPermissionAuthorizationPolicy());
$this->assertSame($factory->createPermissionAuthorizationPolicy(), $factory->createPermissionAuthorizationPolicy());
$this->assertInstanceOf(SettingsAuthorizationPolicy::class, $factory->createSettingsAuthorizationPolicy());
$this->assertSame($factory->createSettingsAuthorizationPolicy(), $factory->createSettingsAuthorizationPolicy());
$this->assertInstanceOf(TenantAuthorizationPolicy::class, $factory->createTenantAuthorizationPolicy());
$this->assertSame($factory->createTenantAuthorizationPolicy(), $factory->createTenantAuthorizationPolicy());
$this->assertInstanceOf(DepartmentAuthorizationPolicy::class, $factory->createDepartmentAuthorizationPolicy());
$this->assertSame($factory->createDepartmentAuthorizationPolicy(), $factory->createDepartmentAuthorizationPolicy());
$this->assertInstanceOf(AuthorizationService::class, $factory->createAuthorizationService());
$this->assertSame($factory->createAuthorizationService(), $factory->createAuthorizationService());
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Service\Access\PermissionGateway;
trait AuthorizationPolicyTestSupport
{
/**
* @param array<int, array<int, string>> $permissionsByUser
*/
private function permissionGatewayAllowing(array $permissionsByUser): PermissionGateway
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission) use ($permissionsByUser): bool {
return in_array($permission, $permissionsByUser[$userId] ?? [], true);
});
return $permissionGateway;
}
private function assertForbiddenDecision(mixed $decision, string $error = 'forbidden'): void
{
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame($error, $decision->error());
}
private function assertDeniedDecision(mixed $decision, int $status, string $error): void
{
$this->assertFalse($decision->isAllowed());
$this->assertSame($status, $decision->status());
$this->assertSame($error, $decision->error());
}
}

View File

@@ -52,5 +52,23 @@ class AuthorizationServiceTest extends TestCase
$this->assertSame(500, $decision->status());
$this->assertSame('authorization_policy_missing', $decision->error());
}
}
public function testAllowReturnsBoolDecisionShortcut(): void
{
$policy = new class implements AuthorizationPolicyInterface {
public function supports(string $ability): bool
{
return $ability === 'users.test';
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return AuthorizationDecision::allow();
}
};
$service = new AuthorizationService([$policy]);
$this->assertTrue($service->allow('users.test', ['actor_user_id' => 7]));
$this->assertFalse($service->allow('users.unknown', ['actor_user_id' => 7]));
}
}

View File

@@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase;
class DepartmentAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testViewRequiresDepartmentsViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -27,22 +29,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'actor_user_id' => 5,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testViewReturnsCreateCapabilityAndTenantScopeData(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return in_array($permission, [PermissionService::DEPARTMENTS_VIEW, PermissionService::DEPARTMENTS_CREATE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::DEPARTMENTS_VIEW, PermissionService::DEPARTMENTS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -82,15 +76,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'actor_user_id' => 7,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testCreateDeniedInStrictScopeWithoutTenantAssignments(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 7 && $permission === PermissionService::DEPARTMENTS_CREATE);
$permissionGateway = $this->permissionGatewayAllowing([
7 => [PermissionService::DEPARTMENTS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -106,16 +99,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'actor_user_id' => 7,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testCreateReturnsScopeCapabilitiesWhenAllowed(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 7 && $permission === PermissionService::DEPARTMENTS_CREATE);
$permissionGateway = $this->permissionGatewayAllowing([
7 => [PermissionService::DEPARTMENTS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -149,16 +140,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'target_department_id' => 17,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testEditContextDeniesOutOfScopeDepartment(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::DEPARTMENTS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::DEPARTMENTS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -172,21 +161,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'target_department_id' => 17,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testEditSubmitRequiresDepartmentsUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return $permission === PermissionService::DEPARTMENTS_VIEW;
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::DEPARTMENTS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -204,15 +186,14 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'target_department_id' => 17,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('forbidden', $decision->error());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testDeleteRequiresPermissionAndScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::DEPARTMENTS_DELETE);
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::DEPARTMENTS_DELETE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -231,9 +212,9 @@ class DepartmentAuthorizationPolicyTest extends TestCase
public function testDeleteDeniesOutOfScopeDepartment(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::DEPARTMENTS_DELETE);
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::DEPARTMENTS_DELETE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -247,7 +228,6 @@ class DepartmentAuthorizationPolicyTest extends TestCase
'target_department_id' => 44,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
}

View File

@@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase;
class PermissionAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testAdminViewRequiresPermissionsViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -22,22 +24,14 @@ class PermissionAuthorizationPolicyTest extends TestCase
'actor_user_id' => 9,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testAdminViewIncludesCreateCapability(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return in_array($permission, [PermissionService::PERMISSIONS_VIEW, PermissionService::PERMISSIONS_CREATE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::PERMISSIONS_VIEW, PermissionService::PERMISSIONS_CREATE],
]);
$policy = new PermissionAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW, [
@@ -61,25 +55,18 @@ class PermissionAuthorizationPolicyTest extends TestCase
'actor_user_id' => 3,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testEditContextReturnsCapabilitiesAndSystemDeleteRestriction(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return in_array($permission, [
PermissionService::PERMISSIONS_VIEW,
PermissionService::PERMISSIONS_UPDATE,
PermissionService::PERMISSIONS_DELETE,
], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [
PermissionService::PERMISSIONS_VIEW,
PermissionService::PERMISSIONS_UPDATE,
PermissionService::PERMISSIONS_DELETE,
],
]);
$policy = new PermissionAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT, [
@@ -96,15 +83,9 @@ class PermissionAuthorizationPolicyTest extends TestCase
public function testEditSubmitRequiresPermissionsUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return $permission === PermissionService::PERMISSIONS_VIEW;
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::PERMISSIONS_VIEW],
]);
$policy = new PermissionAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT, [
@@ -113,9 +94,7 @@ class PermissionAuthorizationPolicyTest extends TestCase
'target_is_system' => false,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testDeleteRequiresPermissionsDeletePermission(): void
@@ -131,7 +110,6 @@ class PermissionAuthorizationPolicyTest extends TestCase
'actor_user_id' => 12,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Access\PermissionService;
use PHPUnit\Framework\TestCase;
class PermissionGatewayTest extends TestCase
{
public function testUserHasDelegatesToService(): void
{
$permissionService = $this->createMock(PermissionService::class);
$permissionService->expects($this->once())
->method('userHas')
->with(7, 'users.view')
->willReturn(true);
$gateway = new PermissionGateway($permissionService);
$this->assertTrue($gateway->userHas(7, 'users.view'));
}
public function testListPagedDelegatesToService(): void
{
$permissionService = $this->createMock(PermissionService::class);
$permissionService->expects($this->once())
->method('listPaged')
->with(['limit' => 10])
->willReturn(['data' => [], 'total' => 0]);
$gateway = new PermissionGateway($permissionService);
$this->assertSame(['data' => [], 'total' => 0], $gateway->listPaged(['limit' => 10]));
}
}

View File

@@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase;
class RoleAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testAdminViewRequiresRolesViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -22,22 +24,14 @@ class RoleAuthorizationPolicyTest extends TestCase
'actor_user_id' => 7,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testAdminViewIncludesCreateCapability(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 7) {
return false;
}
return in_array($permission, [PermissionService::ROLES_VIEW, PermissionService::ROLES_CREATE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
7 => [PermissionService::ROLES_VIEW, PermissionService::ROLES_CREATE],
]);
$policy = new RoleAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW, [
@@ -61,8 +55,7 @@ class RoleAuthorizationPolicyTest extends TestCase
'actor_user_id' => 11,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testEditContextRequiresTargetRoleId(): void
@@ -76,24 +69,14 @@ class RoleAuthorizationPolicyTest extends TestCase
'target_role_id' => 0,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testEditContextReturnsCapabilities(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return in_array($permission, [
PermissionService::ROLES_VIEW,
PermissionService::ROLES_UPDATE,
], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::ROLES_VIEW, PermissionService::ROLES_UPDATE],
]);
$policy = new RoleAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT, [
@@ -109,15 +92,9 @@ class RoleAuthorizationPolicyTest extends TestCase
public function testEditSubmitRequiresRolesUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 5) {
return false;
}
return $permission === PermissionService::ROLES_VIEW;
});
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::ROLES_VIEW],
]);
$policy = new RoleAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT, [
@@ -125,9 +102,7 @@ class RoleAuthorizationPolicyTest extends TestCase
'target_role_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testDeleteRequiresRolesDeletePermission(): void
@@ -143,7 +118,6 @@ class RoleAuthorizationPolicyTest extends TestCase
'actor_user_id' => 6,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
}

View File

@@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase;
class SettingsAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testViewRequiresSettingsViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -22,22 +24,14 @@ class SettingsAuthorizationPolicyTest extends TestCase
'actor_user_id' => 4,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testViewReturnsUpdateCapability(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 4) {
return false;
}
return in_array($permission, [PermissionService::SETTINGS_VIEW, PermissionService::SETTINGS_UPDATE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
4 => [PermissionService::SETTINGS_VIEW, PermissionService::SETTINGS_UPDATE],
]);
$policy = new SettingsAuthorizationPolicy($permissionGateway);
$decision = $policy->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, [
@@ -61,8 +55,7 @@ class SettingsAuthorizationPolicyTest extends TestCase
'actor_user_id' => 8,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testBrandingUpdateRequiresSettingsUpdatePermission(): void
@@ -78,8 +71,7 @@ class SettingsAuthorizationPolicyTest extends TestCase
'actor_user_id' => 8,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testTokenRevokeRequiresSettingsUpdatePermission(): void
@@ -95,8 +87,7 @@ class SettingsAuthorizationPolicyTest extends TestCase
'actor_user_id' => 8,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testUserLifecycleRunRequiresSettingsUpdatePermission(): void
@@ -112,7 +103,6 @@ class SettingsAuthorizationPolicyTest extends TestCase
'actor_user_id' => 8,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
}

View File

@@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase;
class TenantAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testViewRequiresTenantsViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -28,22 +30,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'actor_user_id' => 11,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testViewReturnsCapabilitiesAndScopeData(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 11) {
return false;
}
return in_array($permission, [PermissionService::TENANTS_VIEW, PermissionService::TENANTS_CREATE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_VIEW, PermissionService::TENANTS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -85,21 +79,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'actor_user_id' => 11,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testCreateReturnsSsoAndCustomFieldCapabilities(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 11) {
return false;
}
return in_array($permission, [PermissionService::TENANTS_CREATE, PermissionService::TENANTS_SSO_MANAGE], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_CREATE, PermissionService::TENANTS_SSO_MANAGE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new TenantAuthorizationPolicy($permissionGateway, $scopeGateway);
@@ -115,9 +102,9 @@ class TenantAuthorizationPolicyTest extends TestCase
public function testEditContextAllowsInScopeViewer(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 11 && $permission === PermissionService::TENANTS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -152,16 +139,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'target_tenant_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testEditSubmitRequiresTenantsUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 11 && $permission === PermissionService::TENANTS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -176,18 +161,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'input' => ['description' => 'Tenant A'],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testEditSubmitDeniesSsoFieldsWithoutSsoPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
return $userId === 11 && ($permission === PermissionService::TENANTS_VIEW || $permission === PermissionService::TENANTS_UPDATE);
});
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_VIEW, PermissionService::TENANTS_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -202,16 +183,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'input' => ['microsoft_enabled' => '1'],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testCustomFieldsManageRequiresPermissionAndScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::CUSTOM_FIELDS_MANAGE);
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::CUSTOM_FIELDS_MANAGE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -230,9 +209,9 @@ class TenantAuthorizationPolicyTest extends TestCase
public function testCustomFieldsManageDeniedWhenOutOfScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::CUSTOM_FIELDS_MANAGE);
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::CUSTOM_FIELDS_MANAGE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -246,15 +225,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'target_tenant_id' => 5,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testAvatarViewAllowsTenantViewerInScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 15 && $permission === PermissionService::TENANTS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
15 => [PermissionService::TENANTS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -283,15 +261,14 @@ class TenantAuthorizationPolicyTest extends TestCase
'target_tenant_id' => 9,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('forbidden', $decision->error());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testDeleteRequiresPermissionAndScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::TENANTS_DELETE);
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::TENANTS_DELETE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())

View File

@@ -0,0 +1,94 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\OperationsAuthorizationPolicy;
use MintyPHP\Service\Access\UiCapabilityMap;
use MintyPHP\Service\Access\UiAccessService;
use PHPUnit\Framework\TestCase;
class UiAccessServiceTest extends TestCase
{
public function testAllowCachesDecisionPerAbilityAndContext(): void
{
$authorizationService = $this->createMock(AuthorizationService::class);
$authorizationService->expects($this->once())
->method('authorize')
->with(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, [
'actor_user_id' => 7,
'scoped_tenant_id' => 3,
])
->willReturn(AuthorizationDecision::allow());
$service = new UiAccessService($authorizationService);
$this->assertTrue($service->allow(7, OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, ['scoped_tenant_id' => 3]));
$this->assertTrue($service->allow(7, OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, ['scoped_tenant_id' => 3]));
}
public function testResolveMapSupportsStringAndStructuredDefinition(): void
{
$authorizationService = $this->createMock(AuthorizationService::class);
$authorizationService->expects($this->exactly(2))
->method('authorize')
->willReturnCallback(static function (string $ability, array $context): AuthorizationDecision {
if ($ability === OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW) {
return AuthorizationDecision::allow();
}
if ($ability === OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW
&& (int) ($context['target_tenant_id'] ?? 0) === 99) {
return AuthorizationDecision::allow();
}
return AuthorizationDecision::deny(403, 'forbidden');
});
$service = new UiAccessService($authorizationService);
$result = $service->resolveMap(11, [
'stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
'mail_log' => [
'ability' => OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW,
'context' => ['target_tenant_id' => 99],
],
]);
$this->assertSame([
'stats' => true,
'mail_log' => true,
], $result);
}
public function testLayoutCapabilitiesExposeExpectedKeys(): void
{
$authorizationService = $this->createMock(AuthorizationService::class);
$authorizationService->method('authorize')->willReturn(AuthorizationDecision::deny(403, 'forbidden'));
$service = new UiAccessService($authorizationService);
$result = $service->layoutCapabilities(5);
foreach (array_keys(UiCapabilityMap::LAYOUT) as $key) {
$this->assertArrayHasKey($key, $result);
$this->assertFalse($result[$key]);
}
}
public function testPageCapabilitiesUsesResolveMapContract(): void
{
$authorizationService = $this->createMock(AuthorizationService::class);
$authorizationService->expects($this->once())
->method('authorize')
->with(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, [
'actor_user_id' => 9,
])
->willReturn(AuthorizationDecision::allow());
$service = new UiAccessService($authorizationService);
$result = $service->pageCapabilities(9, [
'can_view_jobs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW,
]);
$this->assertSame(['can_view_jobs' => true], $result);
}
}

View File

@@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase;
class UserAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testAdminViewRequiresUsersViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
@@ -24,9 +26,7 @@ class UserAuthorizationPolicyTest extends TestCase
'actor_user_id' => 13,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testAdminCreateRequiresUsersCreatePermission(): void
@@ -43,8 +43,7 @@ class UserAuthorizationPolicyTest extends TestCase
'actor_user_id' => 13,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testAdminActivateRequiresUsersUpdatePermission(): void
@@ -64,16 +63,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testAdminActivateReturnsPermissionDeniedWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 11 && $permission === PermissionService::USERS_UPDATE);
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::USERS_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -87,16 +84,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testAdminDeactivateAllowsSelfWithoutScopeLookup(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::USERS_UPDATE);
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::USERS_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
@@ -126,15 +121,14 @@ class UserAuthorizationPolicyTest extends TestCase
'bulk_action' => 'delete',
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testAdminDeleteReturnsPermissionDeniedWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 7 && $permission === PermissionService::USERS_DELETE);
$permissionGateway = $this->permissionGatewayAllowing([
7 => [PermissionService::USERS_DELETE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -148,9 +142,7 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 17,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testAdminAccessPdfBulkRequiresPermission(): void
@@ -169,15 +161,14 @@ class UserAuthorizationPolicyTest extends TestCase
'actor_user_id' => 21,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testAdminApiTokensManageRequiresScopeForOtherUsers(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 8 && $permission === PermissionService::API_TOKENS_MANAGE);
$permissionGateway = $this->permissionGatewayAllowing([
8 => [PermissionService::API_TOKENS_MANAGE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -191,25 +182,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 18,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testAdminSendAccessAllowsSelfWithSelfUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 30) {
return false;
}
if ($permission === PermissionService::USERS_SELF_UPDATE) {
return true;
}
return false;
});
$permissionGateway = $this->permissionGatewayAllowing([
30 => [PermissionService::USERS_SELF_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
@@ -225,15 +205,9 @@ class UserAuthorizationPolicyTest extends TestCase
public function testEditContextAllowsOwnAccountWithUsersSelfUpdate(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 30) {
return false;
}
return $permission === PermissionService::USERS_SELF_UPDATE;
});
$permissionGateway = $this->permissionGatewayAllowing([
30 => [PermissionService::USERS_SELF_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
@@ -272,22 +246,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 66,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testEditContextHidesSecurityArtifactsWithoutAuditPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return $permission === PermissionService::USERS_VIEW;
});
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::USERS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -311,15 +277,9 @@ class UserAuthorizationPolicyTest extends TestCase
public function testEditContextShowsSecurityArtifactsWithAuditPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return in_array($permission, [PermissionService::USERS_VIEW, PermissionService::USERS_VIEW_AUDIT], true);
});
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::USERS_VIEW, PermissionService::USERS_VIEW_AUDIT],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -343,15 +303,9 @@ class UserAuthorizationPolicyTest extends TestCase
public function testEditSubmitRejectsUserWithoutEditCapability(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return $permission === PermissionService::USERS_VIEW;
});
$permissionGateway = $this->permissionGatewayAllowing([
9 => [PermissionService::USERS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -369,16 +323,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 55,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
$this->assertForbiddenDecision($decision);
}
public function testAvatarUploadAllowsOwnAccountWithSelfUpdate(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 19 && $permission === PermissionService::USERS_SELF_UPDATE);
$permissionGateway = $this->permissionGatewayAllowing([
19 => [PermissionService::USERS_SELF_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
@@ -407,15 +359,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 42,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
$this->assertDeniedDecision($decision, 403, 'permission_denied');
}
public function testAvatarViewAllowsInScopeAddressBookViewer(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::ADDRESS_BOOK_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::ADDRESS_BOOK_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -449,15 +400,14 @@ class UserAuthorizationPolicyTest extends TestCase
'target_user_id' => 70,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('forbidden', $decision->error());
$this->assertDeniedDecision($decision, 403, 'forbidden');
}
public function testApiShowReturnsNotFoundWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 4 && $permission === PermissionService::USERS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
4 => [PermissionService::USERS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -472,16 +422,14 @@ class UserAuthorizationPolicyTest extends TestCase
'scoped_tenant_id' => null,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(404, $decision->status());
$this->assertSame('not_found', $decision->error());
$this->assertDeniedDecision($decision, 404, 'not_found');
}
public function testApiShowReturnsNotFoundForScopedTokenCrossTenantResource(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 4 && $permission === PermissionService::USERS_VIEW);
$permissionGateway = $this->permissionGatewayAllowing([
4 => [PermissionService::USERS_VIEW],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -500,16 +448,14 @@ class UserAuthorizationPolicyTest extends TestCase
'scoped_tenant_id' => 2,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(404, $decision->status());
$this->assertSame('not_found', $decision->error());
$this->assertDeniedDecision($decision, 404, 'not_found');
}
public function testApiCreateForScopedTokenRejectsOutOfScopeTenantIds(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::USERS_CREATE);
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::USERS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
@@ -520,16 +466,14 @@ class UserAuthorizationPolicyTest extends TestCase
'input' => ['tenant_ids' => [9, 10]],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('tenant_scoped_token_forbidden', $decision->error());
$this->assertDeniedDecision($decision, 403, 'tenant_scoped_token_forbidden');
}
public function testApiCreateForScopedTokenEnforcesScopedTenantInInput(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::USERS_CREATE);
$permissionGateway = $this->permissionGatewayAllowing([
5 => [PermissionService::USERS_CREATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
@@ -547,9 +491,9 @@ class UserAuthorizationPolicyTest extends TestCase
public function testApiUpdateForScopedTokenRejectsOutOfScopePrimaryTenant(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 6 && $permission === PermissionService::USERS_UPDATE);
$permissionGateway = $this->permissionGatewayAllowing([
6 => [PermissionService::USERS_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
@@ -569,8 +513,6 @@ class UserAuthorizationPolicyTest extends TestCase
'input' => ['primary_tenant_id' => 7],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('tenant_scoped_token_forbidden', $decision->error());
$this->assertDeniedDecision($decision, 403, 'tenant_scoped_token_forbidden');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace MintyPHP\Tests\Service\AddressBook;
use MintyPHP\Service\AddressBook\AddressBookAvatarGateway;
use MintyPHP\Service\AddressBook\AddressBookCustomFieldGateway;
use MintyPHP\Service\AddressBook\AddressBookDirectoryGateway;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use PHPUnit\Framework\TestCase;
class AddressBookServiceTest extends TestCase
{
public function testBuildIndexContextBuildsTenantDepartmentMapForGlobalScope(): void
{
$userAccountService = $this->createMock(UserAccountService::class);
$userAssignmentService = $this->createMock(UserAssignmentService::class);
$directoryGateway = $this->createMock(AddressBookDirectoryGateway::class);
$customFieldGateway = $this->createMock(AddressBookCustomFieldGateway::class);
$avatarGateway = $this->createMock(AddressBookAvatarGateway::class);
$directoryGateway
->expects($this->once())
->method('getUserTenantIds')
->with(7)
->willReturn([]);
$directoryGateway
->expects($this->once())
->method('listTenants')
->willReturn([
['id' => 1, 'uuid' => 'tenant-a', 'description' => 'Tenant A'],
['id' => 2, 'uuid' => 'tenant-b', 'description' => 'Tenant B'],
]);
$directoryGateway
->expects($this->exactly(2))
->method('isStrictScope')
->willReturn(false);
$directoryGateway
->expects($this->once())
->method('listDepartments')
->willReturn([
['id' => 10, 'tenant_id' => 1, 'description' => 'Sales'],
['id' => 20, 'tenant_id' => 2, 'description' => 'Support'],
]);
$directoryGateway
->expects($this->never())
->method('listDepartmentsByTenantIds');
$directoryGateway
->expects($this->once())
->method('listActiveRoles')
->willReturn([]);
$customFieldGateway
->expects($this->once())
->method('extractFilterSpec')
->with([], 7)
->willReturn([
'definitions' => [],
'query' => [],
]);
$customFieldGateway
->expects($this->once())
->method('listOptionsByDefinitionIds')
->with([], true)
->willReturn([]);
$service = new AddressBookService(
$userAccountService,
$userAssignmentService,
$directoryGateway,
$customFieldGateway,
$avatarGateway
);
$context = $service->buildIndexContext(7, []);
$this->assertSame(
[
'tenant-a' => ['10'],
'tenant-b' => ['20'],
],
$context['tenantDepartmentMap']
);
}
}

View File

@@ -1,38 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\AddressBook;
use MintyPHP\Service\AddressBook\AddressBookAvatarGateway;
use MintyPHP\Service\AddressBook\AddressBookCustomFieldGateway;
use MintyPHP\Service\AddressBook\AddressBookDirectoryGateway;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use PHPUnit\Framework\TestCase;
class AddressBookServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new AddressBookServicesFactory();
$directoryGatewayA = $factory->createAddressBookDirectoryGateway();
$directoryGatewayB = $factory->createAddressBookDirectoryGateway();
$this->assertInstanceOf(AddressBookDirectoryGateway::class, $directoryGatewayA);
$this->assertSame($directoryGatewayA, $directoryGatewayB);
$customFieldGatewayA = $factory->createAddressBookCustomFieldGateway();
$customFieldGatewayB = $factory->createAddressBookCustomFieldGateway();
$this->assertInstanceOf(AddressBookCustomFieldGateway::class, $customFieldGatewayA);
$this->assertSame($customFieldGatewayA, $customFieldGatewayB);
$avatarGatewayA = $factory->createAddressBookAvatarGateway();
$avatarGatewayB = $factory->createAddressBookAvatarGateway();
$this->assertInstanceOf(AddressBookAvatarGateway::class, $avatarGatewayA);
$this->assertSame($avatarGatewayA, $avatarGatewayB);
$addressBookServiceA = $factory->createAddressBookService();
$addressBookServiceB = $factory->createAddressBookService();
$this->assertInstanceOf(AddressBookService::class, $addressBookServiceA);
$this->assertSame($addressBookServiceA, $addressBookServiceB);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
class ApiAuditServiceTest extends TestCase
{
private array $serverBackup = [];
private array $getBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$this->getBackup = $_GET;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
$_GET = $this->getBackup;
RequestContext::resetForTests();
}
public function testCurrentRequestIdIsNullBeforeStart(): void
{
$service = new ApiAuditService(new ApiAuditLogRepository());
$this->assertNull($service->currentRequestId());
}
public function testCurrentRequestIdIsGeneratedForRegularRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/me?x=1';
$_GET = ['x' => '1'];
$service = new ApiAuditService(new ApiAuditLogRepository());
$service->startRequestContext();
$requestId = $service->currentRequestId();
$this->assertNotNull($requestId);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$requestId
);
$service->startRequestContext();
$this->assertSame($requestId, $service->currentRequestId());
}
public function testCurrentRequestIdStaysNullForOptionsRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
$_SERVER['REQUEST_URI'] = '/api/v1/me';
$_GET = [];
$service = new ApiAuditService(new ApiAuditLogRepository());
$service->startRequestContext();
$this->assertNull($service->currentRequestId());
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
use MintyPHP\Repository\Audit\UserLifecycleAuditRepository;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use PHPUnit\Framework\TestCase;
class AuditServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new AuditServicesFactory();
$this->assertInstanceOf(ApiAuditLogRepository::class, $factory->createApiAuditLogRepository());
$this->assertSame($factory->createApiAuditLogRepository(), $factory->createApiAuditLogRepository());
$this->assertInstanceOf(UserLifecycleAuditRepository::class, $factory->createUserLifecycleAuditRepository());
$this->assertSame($factory->createUserLifecycleAuditRepository(), $factory->createUserLifecycleAuditRepository());
$this->assertInstanceOf(ApiAuditService::class, $factory->createApiAuditService());
$this->assertSame($factory->createApiAuditService(), $factory->createApiAuditService());
$this->assertInstanceOf(UserLifecycleAuditService::class, $factory->createUserLifecycleAuditService());
$this->assertSame($factory->createUserLifecycleAuditService(), $factory->createUserLifecycleAuditService());
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Service\Audit\SystemAuditRedactionService;
use PHPUnit\Framework\TestCase;
class SystemAuditRedactionServiceTest extends TestCase
{
public function testSensitiveKeysAreRedacted(): void
{
$service = new SystemAuditRedactionService();
$redacted = $service->redactMetadata([
'email' => 'john@example.com',
'token' => 'abc',
'active' => 1,
'nested' => ['password' => 'secret'],
]);
$this->assertSame('[REDACTED]', $redacted['email']);
$this->assertSame('[REDACTED]', $redacted['token']);
$this->assertSame(1, $redacted['active']);
$this->assertSame('[REDACTED]', $redacted['nested']['password']);
}
public function testBuildChangeSetUsesAllowlistAndRedactsOtherValues(): void
{
$service = new SystemAuditRedactionService();
$changeSet = $service->buildChangeSet(
['active' => 0, 'description' => 'old'],
['active' => 1, 'description' => 'new']
);
$this->assertSame(['active', 'description'], $changeSet['changed_fields']);
$this->assertSame(0, $changeSet['changes']['active']['before']);
$this->assertSame(1, $changeSet['changes']['active']['after']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['before']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['after']);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
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 PHPUnit\Framework\TestCase;
class SystemAuditServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
}
public function testRecordReturnsNullWhenDisabled(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->never())->method('create');
$settings = $this->createMock(SettingGateway::class);
$settings->expects($this->once())->method('isSystemAuditEnabled')->willReturn(false);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testRecordWritesWhenEnabled(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/users/edit/abc';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['HTTP_USER_AGENT'] = 'phpunit';
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('create')->willReturn(5);
$settings = $this->createMock(SettingGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$id = $service->record('admin.users.update', 'success', [
'target_type' => 'user',
'target_id' => 10,
'before' => ['active' => 0],
'after' => ['active' => 1],
]);
$this->assertSame(5, $id);
}
public function testRecordIsFailOpenOnRepositoryError(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db down'));
$settings = $this->createMock(SettingGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testPurgeUsesConfiguredRetention(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('purgeOlderThanDays')->with(120)->willReturn(3);
$settings = $this->createMock(SettingGateway::class);
$settings->method('getSystemAuditRetentionDays')->willReturn(120);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings);
$this->assertSame(3, $service->purgeExpired());
}
}

View File

@@ -1,68 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Auth\MicrosoftOidcService;
use MintyPHP\Service\Auth\MicrosoftOidcStateStoreService;
use MintyPHP\Service\Auth\PasswordResetService;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Auth\SsoUserLinkService;
use MintyPHP\Service\Auth\TenantSsoService;
use PHPUnit\Framework\TestCase;
class AuthServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new AuthServicesFactory();
$apiTokenServiceA = $factory->createApiTokenService();
$apiTokenServiceB = $factory->createApiTokenService();
$this->assertInstanceOf(ApiTokenService::class, $apiTokenServiceA);
$this->assertSame($apiTokenServiceA, $apiTokenServiceB);
$passwordResetServiceA = $factory->createPasswordResetService();
$passwordResetServiceB = $factory->createPasswordResetService();
$this->assertInstanceOf(PasswordResetService::class, $passwordResetServiceA);
$this->assertSame($passwordResetServiceA, $passwordResetServiceB);
$emailVerificationServiceA = $factory->createEmailVerificationService();
$emailVerificationServiceB = $factory->createEmailVerificationService();
$this->assertInstanceOf(EmailVerificationService::class, $emailVerificationServiceA);
$this->assertSame($emailVerificationServiceA, $emailVerificationServiceB);
$rememberMeServiceA = $factory->createRememberMeService();
$rememberMeServiceB = $factory->createRememberMeService();
$this->assertInstanceOf(RememberMeService::class, $rememberMeServiceA);
$this->assertSame($rememberMeServiceA, $rememberMeServiceB);
$authServiceA = $factory->createAuthService();
$authServiceB = $factory->createAuthService();
$this->assertInstanceOf(AuthService::class, $authServiceA);
$this->assertSame($authServiceA, $authServiceB);
$ssoUserLinkServiceA = $factory->createSsoUserLinkService();
$ssoUserLinkServiceB = $factory->createSsoUserLinkService();
$this->assertInstanceOf(SsoUserLinkService::class, $ssoUserLinkServiceA);
$this->assertSame($ssoUserLinkServiceA, $ssoUserLinkServiceB);
$tenantSsoServiceA = $factory->createTenantSsoService();
$tenantSsoServiceB = $factory->createTenantSsoService();
$this->assertInstanceOf(TenantSsoService::class, $tenantSsoServiceA);
$this->assertSame($tenantSsoServiceA, $tenantSsoServiceB);
$stateStoreA = $factory->createMicrosoftOidcStateStore();
$stateStoreB = $factory->createMicrosoftOidcStateStore();
$this->assertInstanceOf(MicrosoftOidcStateStoreService::class, $stateStoreA);
$this->assertSame($stateStoreA, $stateStoreB);
$microsoftOidcServiceA = $factory->createMicrosoftOidcService();
$microsoftOidcServiceB = $factory->createMicrosoftOidcService();
$this->assertInstanceOf(MicrosoftOidcService::class, $microsoftOidcServiceA);
$this->assertSame($microsoftOidcServiceA, $microsoftOidcServiceB);
}
}

View File

@@ -28,12 +28,12 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
$store->store('state-a', ['tenant_id' => 5, 'nonce' => 'n1']);
$first = $store->consume('state-a');
$this->assertTrue($first['ok'] ?? false);
$this->assertTrue($first['ok']);
$this->assertSame(5, (int) (($first['entry']['tenant_id'] ?? 0)));
$second = $store->consume('state-a');
$this->assertFalse($second['ok'] ?? true);
$this->assertSame('state_invalid', (string) ($second['error'] ?? ''));
$this->assertFalse($second['ok']);
$this->assertSame('state_invalid', (string) $second['error']);
}
public function testConsumeReturnsExpiredForOldState(): void
@@ -45,8 +45,8 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
]);
$result = $store->consume('state-old');
$this->assertFalse($result['ok'] ?? true);
$this->assertSame('state_expired', (string) ($result['error'] ?? ''));
$this->assertFalse($result['ok']);
$this->assertSame('state_expired', (string) $result['error']);
}
public function testStoreRespectsMaxEntriesAndPrunesOldStates(): void
@@ -58,13 +58,13 @@ class MicrosoftOidcStateStoreServiceTest extends TestCase
$store->store('state-3', ['created_at' => time() - 1]);
$invalid = $store->consume('state-1');
$this->assertFalse($invalid['ok'] ?? true);
$this->assertSame('state_invalid', (string) ($invalid['error'] ?? ''));
$this->assertFalse($invalid['ok']);
$this->assertSame('state_invalid', (string) $invalid['error']);
$valid2 = $store->consume('state-2');
$this->assertTrue($valid2['ok'] ?? false);
$this->assertTrue($valid2['ok']);
$valid3 = $store->consume('state-3');
$this->assertTrue($valid3['ok'] ?? false);
$this->assertTrue($valid3['ok']);
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Branding;
use MintyPHP\Service\Branding\BrandingFaviconService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use PHPUnit\Framework\TestCase;
class BrandingServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new BrandingServicesFactory();
$this->assertInstanceOf(BrandingLogoService::class, $factory->createBrandingLogoService());
$this->assertSame($factory->createBrandingLogoService(), $factory->createBrandingLogoService());
$this->assertInstanceOf(BrandingFaviconService::class, $factory->createBrandingFaviconService());
$this->assertSame($factory->createBrandingFaviconService(), $factory->createBrandingFaviconService());
}
}

View File

@@ -1,46 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Directory;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantService;
use PHPUnit\Framework\TestCase;
class DirectoryServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new DirectoryServicesFactory();
$this->assertInstanceOf(TenantRepository::class, $factory->createTenantRepository());
$this->assertSame($factory->createTenantRepository(), $factory->createTenantRepository());
$this->assertInstanceOf(DepartmentRepository::class, $factory->createDepartmentRepository());
$this->assertSame($factory->createDepartmentRepository(), $factory->createDepartmentRepository());
$this->assertInstanceOf(RoleRepository::class, $factory->createRoleRepository());
$this->assertSame($factory->createRoleRepository(), $factory->createRoleRepository());
$this->assertInstanceOf(DirectorySettingsGateway::class, $factory->createDirectorySettingsGateway());
$this->assertSame($factory->createDirectorySettingsGateway(), $factory->createDirectorySettingsGateway());
$this->assertInstanceOf(DirectoryScopeGateway::class, $factory->createDirectoryScopeGateway());
$this->assertSame($factory->createDirectoryScopeGateway(), $factory->createDirectoryScopeGateway());
$this->assertInstanceOf(TenantService::class, $factory->createTenantService());
$this->assertSame($factory->createTenantService(), $factory->createTenantService());
$this->assertInstanceOf(DepartmentService::class, $factory->createDepartmentService());
$this->assertSame($factory->createDepartmentService(), $factory->createDepartmentService());
$this->assertInstanceOf(RoleService::class, $factory->createRoleService());
$this->assertSame($factory->createRoleService(), $factory->createRoleService());
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Import;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Import\ImportService;
use MintyPHP\Service\Import\ImportServicesFactory;
use MintyPHP\Service\Import\ImportStateStoreService;
use PHPUnit\Framework\TestCase;
class ImportServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new ImportServicesFactory();
$importServiceA = $factory->createImportService();
$importServiceB = $factory->createImportService();
$this->assertInstanceOf(ImportService::class, $importServiceA);
$this->assertSame($importServiceA, $importServiceB);
$auditServiceA = $factory->createImportAuditService();
$auditServiceB = $factory->createImportAuditService();
$this->assertInstanceOf(ImportAuditService::class, $auditServiceA);
$this->assertSame($auditServiceA, $auditServiceB);
$stateStoreA = $factory->createImportStateStoreService();
$stateStoreB = $factory->createImportStateStoreService();
$this->assertInstanceOf(ImportStateStoreService::class, $stateStoreA);
$this->assertSame($stateStoreA, $stateStoreB);
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Mail;
use MintyPHP\Repository\Mail\MailLogRepository;
use MintyPHP\Service\Mail\MailLogService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\Service\Mail\MailServicesFactory;
use PHPUnit\Framework\TestCase;
class MailServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new MailServicesFactory();
$this->assertInstanceOf(MailLogRepository::class, $factory->createMailLogRepository());
$this->assertSame($factory->createMailLogRepository(), $factory->createMailLogRepository());
$this->assertInstanceOf(MailService::class, $factory->createMailService());
$this->assertSame($factory->createMailService(), $factory->createMailService());
$this->assertInstanceOf(MailLogService::class, $factory->createMailLogService());
$this->assertSame($factory->createMailLogService(), $factory->createMailLogService());
}
}

View File

@@ -21,7 +21,7 @@ class ScheduleCalculatorTest extends TestCase
$next = $calculator->calculateNextRunUtc($job, $reference);
$this->assertInstanceOf(\DateTimeImmutable::class, $next);
$this->assertSame('2026-02-22 12:00:00', $next?->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'));
$this->assertSame('2026-02-22 12:00:00', $next->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'));
}
public function testCalculateNextRunUtcDailyMovesToNextDayWhenTimeAlreadyPassed(): void
@@ -38,7 +38,7 @@ class ScheduleCalculatorTest extends TestCase
$next = $calculator->calculateNextRunUtc($job, $reference);
$this->assertInstanceOf(\DateTimeImmutable::class, $next);
$this->assertSame('2026-02-23 08:00:00', $next?->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'));
$this->assertSame('2026-02-23 08:00:00', $next->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'));
}
public function testNormalizeWeekdaysCsvRemovesDuplicatesAndInvalidValues(): void
@@ -50,4 +50,3 @@ class ScheduleCalculatorTest extends TestCase
$this->assertSame('1,3,7', $normalized);
}
}

View File

@@ -5,6 +5,8 @@ namespace MintyPHP\Tests\Service\Scheduler;
use MintyPHP\Repository\Scheduler\ScheduledJobRepository;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository;
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepository;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Scheduler\ScheduleCalculator;
use MintyPHP\Service\Scheduler\ScheduledJobRegistry;
use MintyPHP\Service\Scheduler\ScheduledJobService;
@@ -17,6 +19,18 @@ class SchedulerRunServiceTest extends TestCase
{
$scheduledJobService = $this->createMock(ScheduledJobService::class);
$scheduledJobService->expects($this->once())->method('ensureSystemJobs');
$systemAuditService = $this->createMock(SystemAuditService::class);
$systemAuditService->expects($this->once())
->method('record')
->with(
'scheduler.run',
'failed',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['trigger_type'] ?? '') === 'manual'
&& ($metadata['run_status'] ?? '') === 'failed';
})
);
$service = new SchedulerRunService(
$scheduledJobService,
@@ -24,7 +38,9 @@ class SchedulerRunServiceTest extends TestCase
$this->createMock(ScheduledJobRunRepository::class),
$this->createMock(SchedulerRuntimeRepository::class),
$this->createMock(ScheduledJobRegistry::class),
new ScheduleCalculator()
new ScheduleCalculator(),
$this->createMock(DatabaseSessionRepository::class),
$systemAuditService
);
$result = $service->runJobNow(0, 0);
@@ -32,4 +48,95 @@ class SchedulerRunServiceTest extends TestCase
$this->assertFalse($result['ok']);
$this->assertSame('invalid_request', $result['error']);
}
public function testRunJobNowSkippedRunIsAuditedAsSuccessOutcome(): void
{
$scheduledJobService = $this->createMock(ScheduledJobService::class);
$scheduledJobService->expects($this->once())->method('ensureSystemJobs');
$scheduledJobRepository = $this->createMock(ScheduledJobRepository::class);
$scheduledJobRepository->expects($this->once())
->method('find')
->with(7)
->willReturn([
'id' => 7,
'job_key' => 'user_lifecycle_run',
'enabled' => 1,
'next_run_at' => gmdate('Y-m-d H:i:s'),
'catchup_once' => 1,
]);
$scheduledJobRepository->expects($this->once())
->method('markRunning')
->with(7, $this->isType('string'))
->willReturn(false);
$scheduledJobRunRepository = $this->createMock(ScheduledJobRunRepository::class);
$scheduledJobRunRepository->expects($this->once())
->method('create')
->with($this->isType('array'))
->willReturn(1);
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())
->method('acquireAdvisoryLock')
->with('scheduled_jobs_runner', 0)
->willReturn(true);
$databaseSessionRepository->expects($this->once())
->method('releaseAdvisoryLock')
->with('scheduled_jobs_runner');
$records = [];
$systemAuditService = $this->createMock(SystemAuditService::class);
$systemAuditService->expects($this->exactly(2))
->method('record')
->willReturnCallback(static function (string $eventType, string $outcome, array $context) use (&$records): int {
$records[] = [
'event_type' => $eventType,
'outcome' => $outcome,
'context' => $context,
];
return 1;
});
$service = new SchedulerRunService(
$scheduledJobService,
$scheduledJobRepository,
$scheduledJobRunRepository,
$this->createMock(SchedulerRuntimeRepository::class),
$this->createMock(ScheduledJobRegistry::class),
new ScheduleCalculator(),
$databaseSessionRepository,
$systemAuditService
);
$result = $service->runJobNow(7, 42);
$this->assertTrue($result['ok']);
$this->assertSame('skipped', $result['status']);
$this->assertSame('job_already_running', $result['error']);
$this->assertCount(2, $records);
$jobRecord = null;
$runRecord = null;
foreach ($records as $record) {
if (($record['event_type'] ?? '') === 'scheduler.job.run') {
$jobRecord = $record;
}
if (($record['event_type'] ?? '') === 'scheduler.run') {
$runRecord = $record;
}
}
$this->assertNotNull($jobRecord);
$this->assertNotNull($runRecord);
$this->assertSame('success', $jobRecord['outcome']);
$this->assertSame('success', $runRecord['outcome']);
$jobMetadata = is_array($jobRecord['context']['metadata'] ?? null) ? $jobRecord['context']['metadata'] : [];
$runMetadata = is_array($runRecord['context']['metadata'] ?? null) ? $runRecord['context']['metadata'] : [];
$this->assertSame('manual', $jobMetadata['trigger_type'] ?? null);
$this->assertSame('skipped', $jobMetadata['run_status'] ?? null);
$this->assertSame('manual', $runMetadata['trigger_type'] ?? null);
$this->assertSame('skipped', $runMetadata['run_status'] ?? null);
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Scheduler;
use MintyPHP\Service\Scheduler\ScheduledJobService;
use MintyPHP\Service\Scheduler\SchedulerRunService;
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
use MintyPHP\Service\User\UserLifecycleService;
use PHPUnit\Framework\TestCase;
class SchedulerServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new SchedulerServicesFactory();
$scheduledJobServiceA = $factory->createScheduledJobService();
$scheduledJobServiceB = $factory->createScheduledJobService();
$this->assertInstanceOf(ScheduledJobService::class, $scheduledJobServiceA);
$this->assertSame($scheduledJobServiceA, $scheduledJobServiceB);
$schedulerRunServiceA = $factory->createSchedulerRunService();
$schedulerRunServiceB = $factory->createSchedulerRunService();
$this->assertInstanceOf(SchedulerRunService::class, $schedulerRunServiceA);
$this->assertSame($schedulerRunServiceA, $schedulerRunServiceB);
$userLifecycleServiceA = $factory->createUserLifecycleService();
$userLifecycleServiceB = $factory->createUserLifecycleService();
$this->assertInstanceOf(UserLifecycleService::class, $userLifecycleServiceA);
$this->assertSame($userLifecycleServiceA, $userLifecycleServiceB);
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Security;
use MintyPHP\Repository\Security\RateLimitRepository;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Security\SecurityServicesFactory;
use PHPUnit\Framework\TestCase;
class SecurityServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new SecurityServicesFactory();
$this->assertInstanceOf(RateLimitRepository::class, $factory->createRateLimitRepository());
$this->assertSame($factory->createRateLimitRepository(), $factory->createRateLimitRepository());
$this->assertInstanceOf(RateLimiterService::class, $factory->createRateLimiterService());
$this->assertSame($factory->createRateLimiterService(), $factory->createRateLimiterService());
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingService;
use PHPUnit\Framework\TestCase;
class SettingGatewayTest extends TestCase
{
public function testGetDefaultTenantIdDelegatesToService(): void
{
$settingService = $this->createMock(SettingService::class);
$settingService->expects($this->once())
->method('getDefaultTenantId')
->willReturn(42);
$gateway = new SettingGateway($settingService);
$this->assertSame(42, $gateway->getDefaultTenantId());
}
public function testSetDefaultRoleIdDelegatesToService(): void
{
$settingService = $this->createMock(SettingService::class);
$settingService->expects($this->once())
->method('setDefaultRoleId')
->with(7, null)
->willReturn(true);
$gateway = new SettingGateway($settingService);
$this->assertTrue($gateway->setDefaultRoleId(7));
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepository;
use MintyPHP\Service\Settings\SettingCacheService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\ThemeConfigService;
use PHPUnit\Framework\TestCase;
class SettingServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new SettingServicesFactory();
$this->assertInstanceOf(SettingRepository::class, $factory->createSettingRepository());
$this->assertSame($factory->createSettingRepository(), $factory->createSettingRepository());
$this->assertInstanceOf('MintyPHP\\Service\\Settings\\SettingService', $factory->createSettingService());
$this->assertSame($factory->createSettingService(), $factory->createSettingService());
$this->assertInstanceOf(SettingCacheService::class, $factory->createSettingCacheService());
$this->assertSame($factory->createSettingCacheService(), $factory->createSettingCacheService());
$this->assertInstanceOf(ThemeConfigService::class, $factory->createThemeConfigService());
$this->assertSame($factory->createThemeConfigService(), $factory->createThemeConfigService());
$this->assertInstanceOf(SettingGateway::class, $factory->createSettingGateway());
$this->assertSame($factory->createSettingGateway(), $factory->createSettingGateway());
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Tenant;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Tenant\TenantAvatarService;
use MintyPHP\Service\Tenant\TenantFaviconService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use PHPUnit\Framework\TestCase;
class TenantServicesFactoryTest extends TestCase
{
public function testFactoryReturnsStableInstancesPerFactoryObject(): void
{
$factory = new TenantServicesFactory();
$this->assertInstanceOf(TenantRepository::class, $factory->createTenantRepository());
$this->assertSame($factory->createTenantRepository(), $factory->createTenantRepository());
$this->assertInstanceOf(DepartmentRepository::class, $factory->createDepartmentRepository());
$this->assertSame($factory->createDepartmentRepository(), $factory->createDepartmentRepository());
$this->assertInstanceOf(UserTenantRepository::class, $factory->createUserTenantRepository());
$this->assertSame($factory->createUserTenantRepository(), $factory->createUserTenantRepository());
$this->assertInstanceOf(PermissionGateway::class, $factory->createPermissionGateway());
$this->assertSame($factory->createPermissionGateway(), $factory->createPermissionGateway());
$this->assertInstanceOf(TenantScopeService::class, $factory->createTenantScopeService());
$this->assertSame($factory->createTenantScopeService(), $factory->createTenantScopeService());
$this->assertInstanceOf(TenantAvatarService::class, $factory->createTenantAvatarService());
$this->assertSame($factory->createTenantAvatarService(), $factory->createTenantAvatarService());
$this->assertInstanceOf(TenantFaviconService::class, $factory->createTenantFaviconService());
$this->assertSame($factory->createTenantFaviconService(), $factory->createTenantFaviconService());
}
}

View File

@@ -24,4 +24,36 @@ class UserPasswordPolicyServiceTest extends TestCase
$this->assertNotEmpty($errors);
}
public function testValidateRequiresPasswordWhenRequired(): void
{
$service = new UserPasswordPolicyService();
$errors = $service->validate('', '', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
public function testValidateRejectsMismatch(): void
{
$service = new UserPasswordPolicyService();
$errors = $service->validate('Abcd1234$efg', 'Abcd1234$xxx', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
public function testValidateAcceptsStrongPassword(): void
{
$service = new UserPasswordPolicyService();
$errors = $service->validate('StrongPass1!', 'StrongPass1!', true, 'test@example.com');
$this->assertSame([], $errors);
}
public function testValidateRejectsPasswordContainingEmail(): void
{
$service = new UserPasswordPolicyService();
$errors = $service->validate('test@example.comA1!', 'test@example.comA1!', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Service\User\UserServicesFactory;
use PHPUnit\Framework\TestCase;
class UserServicesFactoryTest extends TestCase
{
public function testFactoryCachesCoreInstances(): void
{
$factory = new UserServicesFactory();
$this->assertSame($factory->createUserAccountService(), $factory->createUserAccountService());
$this->assertSame($factory->createUserAssignmentService(), $factory->createUserAssignmentService());
$this->assertSame($factory->createUserTenantContextService(), $factory->createUserTenantContextService());
$this->assertSame($factory->createUserPasswordService(), $factory->createUserPasswordService());
$this->assertSame($factory->createUserReadRepository(), $factory->createUserReadRepository());
$this->assertSame($factory->createUserWriteRepository(), $factory->createUserWriteRepository());
$this->assertSame($factory->createUserListQueryRepository(), $factory->createUserListQueryRepository());
$this->assertSame($factory->createUserSavedFilterRepository(), $factory->createUserSavedFilterRepository());
$this->assertSame($factory->createUserTenantRepository(), $factory->createUserTenantRepository());
$this->assertSame($factory->createUserRoleRepository(), $factory->createUserRoleRepository());
$this->assertSame($factory->createUserDepartmentRepository(), $factory->createUserDepartmentRepository());
$this->assertSame($factory->createUserPasswordPolicyService(), $factory->createUserPasswordPolicyService());
$this->assertSame($factory->createUserAvatarService(), $factory->createUserAvatarService());
$this->assertSame($factory->createUserSavedFilterService(), $factory->createUserSavedFilterService());
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace MintyPHP\Tests\Service;
use MintyPHP\Service\User\UserPasswordPolicyService;
use PHPUnit\Framework\TestCase;
class UserServicePasswordTest extends TestCase
{
private UserPasswordPolicyService $policy;
protected function setUp(): void
{
$this->policy = new UserPasswordPolicyService();
}
public function testRequiresPasswordWhenRequired(): void
{
$errors = $this->policy->validate('', '', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
public function testRejectsMismatch(): void
{
$errors = $this->policy->validate('Abcd1234$efg', 'Abcd1234$xxx', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
public function testAcceptsStrongPassword(): void
{
$errors = $this->policy->validate('StrongPass1!', 'StrongPass1!', true, 'test@example.com');
$this->assertSame([], $errors);
}
public function testRejectsPasswordContainingEmail(): void
{
$errors = $this->policy->validate('test@example.comA1!', 'test@example.comA1!', true, 'test@example.com');
$this->assertNotEmpty($errors);
}
}

View File

@@ -2,3 +2,5 @@
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../lib/Support/helpers.php';
$container = require __DIR__ . '/../lib/App/registerContainer.php';
setAppContainer($container);