diff --git a/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/Helpers/Grid/TrackGridTest.php b/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/Helpers/Grid/TrackGridTest.php new file mode 100644 index 000000000..803ee6df0 --- /dev/null +++ b/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/Helpers/Grid/TrackGridTest.php @@ -0,0 +1,184 @@ +. + */ + +declare(strict_types=1); + +namespace SP\Tests\Integration\Infrastructure\Adapter\In\Web\Controllers\Helpers\Grid; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\Exception; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use SP\Domain\Common\Dtos\QueryResult; +use SP\Infrastructure\Adapter\In\Web\Controllers\Helpers\Grid\TrackGrid; +use SP\Tests\Support\IntegrationTestCase; + +/** + * The track listing is the one showing blocked sign-in attempts, and its address column is the + * same kind of sensitive data the event log masks on a demo instance — an IPv4/IPv6 address that + * identifies where a failed login came from. + * + * The other thing worth pinning down is the Unlock action: it carries a consequence (clearing a + * block early), so it must only be offered on a row that is actually still blocking. The row + * template (grid/datagrid-rows.inc) hides an action whenever the row's field equals the + * configured filter value, so "filter on tracked=0" reads backwards until that is spelled out: + * it hides Unlock on rows where tracked is 0 (not currently blocking) and shows it otherwise. + */ +#[Group('integration')] +class TrackGridTest extends IntegrationTestCase +{ + private bool $demo = false; + + protected function getConfigData(): array + { + return array_merge(parent::getConfigData(), ['isDemoEnabled' => $this->demo]); + } + + /** + * On an ordinary instance the address is shown as it was recorded. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + #[DataProvider('addressColumnProvider')] + public function anAddressIsShownAsRecordedWhenNotOnADemo(string $column, string $address): void + { + $shown = $this->transform($column, inet_pton($address)); + + self::assertSame($address, $shown); + } + + /** + * On a demo instance the address is masked — the track listing is reachable by anybody who + * can sign in, and every blocked attempt carries the address it came from. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + #[DataProvider('addressColumnProvider')] + public function anAddressIsMaskedOnADemo(string $column, string $address): void + { + $this->demo = true; + + self::assertSame('*.*.*.*', $this->transform($column, inet_pton($address))); + } + + /** + * @return array + */ + public static function addressColumnProvider(): array + { + return [ + 'ipv4' => ['ipv4', '192.168.1.50'], + 'ipv6' => ['ipv6', '2001:db8::1'], + ]; + } + + /** + * A track that was never resolved for this protocol (e.g. an IPv6-only client has no ipv4 + * value) renders as blank rather than as an empty or malformed cell. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + public function anUnrecordedAddressIsShownAsBlank(): void + { + self::assertSame(' ', $this->transform('ipv4', null)); + self::assertSame(' ', $this->transform('ipv6', null)); + } + + /** + * Unlock is a consequential action -- it clears a block before it would otherwise expire -- + * so it must be scoped to rows that are still blocking (tracked != 0), not offered on every + * row regardless of state. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + public function unlockIsScopedToRowsStillBlocking(): void + { + $container = $this->buildContainer( + IntegrationTestCase::buildRequest('get', 'index.php', ['r' => 'track/search']) + ); + + /** @var TrackGrid $builder */ + $builder = $container->get(TrackGrid::class); + + $grid = $builder->getGrid(QueryResult::withTotalNumRows([], 0)); + + $unlockAction = null; + + foreach ($grid->getDataActions() as $action) { + if ($action->getTitle() === 'Unlock Track') { + $unlockAction = $action; + } + } + + self::assertNotNull($unlockAction, 'the listing offers no way to unlock a blocked attempt'); + self::assertSame( + [['field' => 'tracked', 'value' => 0]], + $unlockAction->getFilterRowSource(), + 'the row template hides an action when the row value matches, so this must hide ' + . 'Unlock on rows that are not currently blocking (tracked=0)' + ); + } + + /** + * The listing rewrites a row's value through the transformer the grid holds for that column, + * so the transformer is what is asked here -- the same callable the template renders through. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + private function transform(string $column, mixed $value): mixed + { + $container = $this->buildContainer( + IntegrationTestCase::buildRequest('get', 'index.php', ['r' => 'track/search']) + ); + + /** @var TrackGrid $builder */ + $builder = $container->get(TrackGrid::class); + + $grid = $builder->getGrid(QueryResult::withTotalNumRows([], 0)); + + foreach ($grid->getData()->getDataRowSources() as $source) { + if ($source['name'] === $column) { + return ($source['filter'])($value); + } + } + + self::fail(sprintf('The listing has no %s column to rewrite', $column)); + } +} diff --git a/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/Helpers/Account/AccountHistoryHelperTest.php b/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/Helpers/Account/AccountHistoryHelperTest.php new file mode 100644 index 000000000..9ea1a1791 --- /dev/null +++ b/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/Helpers/Account/AccountHistoryHelperTest.php @@ -0,0 +1,365 @@ +. + */ + +declare(strict_types=1); + +namespace SP\Tests\Unit\Infrastructure\Adapter\In\Web\Controllers\Helpers\Account; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\Stub; +use SP\Application\Account\Ports\AccountAclService; +use SP\Application\Account\Ports\AccountHistoryService; +use SP\Application\Account\Ports\AccountToUserGroupService; +use SP\Application\Account\Ports\AccountToUserService; +use SP\Application\Category\Ports\CategoryService; +use SP\Application\Client\Ports\ClientService; +use SP\Application\Crypt\Ports\MasterPassService; +use SP\Domain\Account\Adapters\AccountPermission; +use SP\Domain\Account\Dtos\AccountHistoryViewDto; +use SP\Domain\Common\Models\Simple; +use SP\Domain\Core\Acl\AccountPermissionException; +use SP\Domain\Core\Acl\AclActionsInterface; +use SP\Domain\Core\Acl\AclInterface; +use SP\Domain\Core\Acl\UnauthorizedActionException; +use SP\Domain\Core\Acl\UnauthorizedPageException; +use SP\Domain\Core\Context\Context; +use SP\Domain\Core\Context\SessionContext; +use SP\Domain\Http\Ports\RequestService; +use SP\Domain\User\Dtos\UserDto; +use SP\Domain\User\Models\User; +use SP\Domain\User\Services\UpdatedMasterPassException; +use SP\Infrastructure\Adapter\In\Web\Controllers\Helpers\Account\AccountActionsHelper; +use SP\Infrastructure\Adapter\In\Web\Controllers\Helpers\Account\AccountHistoryHelper; +use SP\Infrastructure\Adapter\In\Web\DataGrid\Action\DataGridActionInterface; +use SP\Infrastructure\Adapter\In\Web\View\TemplateInterface; +use SP\Infrastructure\UI\ThemeIcons; +use SP\Tests\Support\UnitaryTestCase; + +/** + * The history view is where an old, already-superseded version of an account is shown, and the + * one action on it with a real consequence is restoring that version over the current one. That + * has to require the same edit access as any other change to the account, or a viewer who can + * only look at the account's past could reach in and overwrite its present. + * + * The integration suite cannot exercise a refusal here — its ACL is stubbed permanently open — so + * both the page-level guard (initializeFor()'s access/master-password checks) and the + * per-account guard (checkAccess()'s permission check) are pinned in this unit test instead, with + * AccountAclService mocked closed. + */ +#[Group('unitary')] +class AccountHistoryHelperTest extends UnitaryTestCase +{ + private const ACCOUNT_ID = 42; + private const HISTORY_ID = 77; + + private AclInterface|Stub $acl; + private MasterPassService|Stub $masterPassService; + private AccountAclService|Stub $accountAclService; + private AccountHistoryService|Stub $accountHistoryService; + private CategoryService|Stub $categoryService; + private ClientService|Stub $clientService; + private AccountToUserService|Stub $accountToUserService; + private AccountToUserGroupService|Stub $accountToUserGroupService; + + /** @var array What the template was handed, by name */ + private array $assigned = []; + + /** + * Reaching the view at all requires the page-level access check to have already run and + * granted it — a caller that skips initializeFor() (or one that failed it) must not be able + * to render anything. + */ + #[Test] + public function theViewIsRefusedWithoutAPriorAccessGrant(): void + { + $this->expectException(UnauthorizedActionException::class); + + // initializeFor() deliberately not called: actionGranted stays false. + $this->buildHelper()->setViewForAccount($this->buildDto()); + } + + /** + * Passing the page-level check is not the same as being allowed to see this particular + * account's history — that is a separate, per-account permission check. + * + * @throws UnauthorizedPageException + * @throws UpdatedMasterPassException + */ + #[Test] + public function theViewIsRefusedWhenTheAccountItselfIsNotAccessible(): void + { + $helper = $this->helperGrantedAccessToTheHistoryPage(); + + $denied = new AccountPermission(AclActionsInterface::ACCOUNT_HISTORY_VIEW, true); + $denied->setCompiledAccountAccess(true); + $denied->setResultView(false); + + $this->accountAclService->method('getAcl')->willReturn($denied); + + $this->expectException(AccountPermissionException::class); + + $helper->setViewForAccount($this->buildDto()); + } + + /** + * Restoring is offered only on top of the underlying edit access. A viewer who may look at + * the history but has no edit rights over the account must not be offered a way to write to + * it. + * + * @throws UnauthorizedPageException + * @throws UpdatedMasterPassException + * @throws AccountPermissionException + */ + #[Test] + public function restoreIsNotOfferedToAViewerWhoMayNotEditTheAccount(): void + { + $helper = $this->helperGrantedAccessToTheHistoryPage(); + $this->accountAclService->method('getAcl')->willReturn($this->permission(canEdit: false)); + + $helper->setViewForAccount($this->buildDto()); + + self::assertNotContains( + AclActionsInterface::ACCOUNT_EDIT_RESTORE, + $this->assignedActionIds(), + 'a viewer without edit access on the account was offered a way to restore it' + ); + } + + /** + * With the underlying edit access granted, restoring the shown version is offered. + * + * @throws UnauthorizedPageException + * @throws UpdatedMasterPassException + * @throws AccountPermissionException + */ + #[Test] + public function restoreIsOfferedToAViewerWhoMayEditTheAccount(): void + { + $helper = $this->helperGrantedAccessToTheHistoryPage(); + $this->accountAclService->method('getAcl')->willReturn($this->permission(canEdit: true)); + + $helper->setViewForAccount($this->buildDto()); + + self::assertContains( + AclActionsInterface::ACCOUNT_EDIT_RESTORE, + $this->assignedActionIds(), + 'a viewer with edit access on the account was not offered a way to restore it' + ); + } + + /** + * Every history entry but the first was actually edited, so it is labelled with whoever + * changed it and when. + */ + #[Test] + public function anEditedEntryIsLabelledWithItsEditor(): void + { + $entry = new Simple( + [ + 'id' => 5, + 'dateAdd' => '2024-01-01 10:00:00', + 'userAdd' => 'creator', + 'dateEdit' => '2024-02-02 11:00:00', + 'userEdit' => 'editor', + ] + ); + + self::assertSame( + ['5' => '2024-02-02 11:00:00 - editor'], + AccountHistoryHelper::mapHistoryForDateSelect([$entry]) + ); + } + + /** + * The very first entry in an account's history has no editor or edit date yet — it is + * labelled with whoever created the account instead, not with an empty edit date. + */ + #[Test] + public function theFirstEntryIsLabelledWithItsCreator(): void + { + $entry = new Simple( + [ + 'id' => 5, + 'dateAdd' => '2024-01-01 10:00:00', + 'userAdd' => 'creator', + 'dateEdit' => null, + 'userEdit' => null, + ] + ); + + self::assertSame( + ['5' => '2024-01-01 10:00:00 - creator'], + AccountHistoryHelper::mapHistoryForDateSelect([$entry]) + ); + } + + /** + * A zeroed-out edit date (what the schema stores for "never edited") is read the same as an + * empty one, not as a real edit that happened at the epoch. + */ + #[Test] + public function aZeroedEditDateIsTreatedAsNeverEdited(): void + { + $entry = new Simple( + [ + 'id' => 5, + 'dateAdd' => '2024-01-01 10:00:00', + 'userAdd' => 'creator', + 'dateEdit' => '0000-00-00 00:00:00', + 'userEdit' => '', + ] + ); + + self::assertSame( + ['5' => '2024-01-01 10:00:00 - creator'], + AccountHistoryHelper::mapHistoryForDateSelect([$entry]) + ); + } + + /** + * The context has to be session-backed — HelperBase refuses anything else — and carries the + * signed-in user the actions helper reads for the overflow menu. + */ + protected function buildContext(): Context + { + $context = self::createStub(SessionContext::class); + $context->method('getUserData')->willReturn( + UserDto::fromModel(new User(['id' => 1, 'login' => 'someone'])) + ); + + return $context; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->acl = $this->createStub(AclInterface::class); + $this->acl->method('getRouteFor')->willReturnCallback(static fn(int $actionId) => (string)$actionId); + + $this->masterPassService = $this->createStub(MasterPassService::class); + $this->accountAclService = $this->createStub(AccountAclService::class); + $this->accountHistoryService = $this->createStub(AccountHistoryService::class); + $this->accountHistoryService->method('getHistoryForAccount')->willReturn([]); + $this->categoryService = $this->createStub(CategoryService::class); + $this->categoryService->method('getAll')->willReturn([]); + $this->clientService = $this->createStub(ClientService::class); + $this->clientService->method('getAll')->willReturn([]); + $this->accountToUserService = $this->createStub(AccountToUserService::class); + $this->accountToUserService->method('getUsersByAccountId')->willReturn([]); + $this->accountToUserGroupService = $this->createStub(AccountToUserGroupService::class); + $this->accountToUserGroupService->method('getUserGroupsByAccountId')->willReturn([]); + } + + private function buildHelper(): AccountHistoryHelper + { + $view = $this->createStub(TemplateInterface::class); + $view->method('assign') + ->willReturnCallback(function (string $name, mixed $value) { + $this->assigned[$name] = $value; + }); + + $request = $this->createStub(RequestService::class); + + $accountActionsHelper = new AccountActionsHelper( + $this->application, + $view, + $request, + new ThemeIcons(), + $this->acl + ); + + return new AccountHistoryHelper( + $this->application, + $view, + $request, + $this->acl, + $accountActionsHelper, + $this->masterPassService, + $this->accountHistoryService, + $this->accountAclService, + $this->categoryService, + $this->clientService, + $this->accountToUserService, + $this->accountToUserGroupService + ); + } + + /** + * A helper that already passed the page-level guard (initializeFor()) — the access and + * master-password checks a real request goes through before AccountHistoryHelper is asked to + * render anything. + * + * @throws UnauthorizedPageException + * @throws UpdatedMasterPassException + */ + private function helperGrantedAccessToTheHistoryPage(): AccountHistoryHelper + { + $this->acl->method('checkUserAccess')->willReturn(true); + $this->masterPassService->method('checkUserUpdateMPass')->willReturn(true); + + $helper = $this->buildHelper(); + $helper->initializeFor(AclActionsInterface::ACCOUNT_HISTORY_VIEW); + + return $helper; + } + + private function permission(bool $canEdit): AccountPermission + { + $permission = new AccountPermission(AclActionsInterface::ACCOUNT_HISTORY_VIEW, true); + $permission->setCompiledAccountAccess(true); + $permission->setResultView(true); + $permission->setResultEdit($canEdit); + $permission->setShowRestore(true); + + return $permission; + } + + private function buildDto(): AccountHistoryViewDto + { + return new AccountHistoryViewDto( + userId: 1, + userGroupId: 1, + dateEdit: '2024-01-01 10:00:00', + accountId: self::ACCOUNT_ID, + id: self::HISTORY_ID, + passDateChange: 0, + categoryId: 1, + clientId: 1, + passDate: 0 + ); + } + + /** + * @return int[] + */ + private function assignedActionIds(): array + { + /** @var DataGridActionInterface[] $actions */ + $actions = $this->assigned['accountActions'] ?? []; + + return array_map(static fn(DataGridActionInterface $action) => (int)$action->getId(), $actions); + } +}