diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php new file mode 100644 index 00000000000..00f62c39987 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceNames.php @@ -0,0 +1,56 @@ + + * @api + */ +final readonly class WorkspaceNames implements \IteratorAggregate, \Countable +{ + /** @param array $items */ + private function __construct( + private array $items + ) { + } + + public static function create( + WorkspaceName ...$items + ): self { + $indexed = []; + foreach ($items as $item) { + $indexed[$item->value] = $item; + } + return new self( + $indexed + ); + } + + public static function fromWorkspaces(Workspaces $workspaces): self + { + return WorkspaceNames::create(...$workspaces->map(fn (Workspace $workspace) => $workspace->workspaceName)); + } + + public function contain(WorkspaceName $workspaceName): bool + { + return array_key_exists($workspaceName->value, $this->items); + } + + /** @return list */ + public function toStringArray(): array + { + return array_keys($this->items); + } + + public function getIterator(): \Traversable + { + yield from array_values($this->items); + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php new file mode 100644 index 00000000000..071a09ed79e --- /dev/null +++ b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNamesTest.php @@ -0,0 +1,63 @@ +contain($nameInstance)); + self::assertTrue($workspaceNames->contain(WorkspaceName::fromString('foo'))); + self::assertFalse($workspaceNames->contain(WorkspaceName::fromString('not-included'))); + } + + /** + * @test + */ + public function fromWorkspaces() + { + $workspaces = Workspaces::fromArray([ + self::workspace('root', null), + self::workspace('regular', 'root'), + self::workspace('other-root', null), + ]); + + self::assertEquals( + WorkspaceNames::create( + WorkspaceName::fromString('root'), + WorkspaceName::fromString('regular'), + WorkspaceName::fromString('other-root'), + ), + WorkspaceNames::fromWorkspaces($workspaces) + ); + } + + private static function workspace(string $name): Workspace + { + return Workspace::create( + WorkspaceName::fromString($name), + null, + ContentStreamId::create(), + WorkspaceStatus::UP_TO_DATE, + false + ); + } +} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index b3d4aaf0cc4..4407e570bd1 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -136,16 +136,24 @@ public function iExpectTheFollowingWorkspaces(TableNode $payloadTable): void { $actualComparableHash = []; $workspaces = $this->currentContentRepository->findWorkspaces(); + + // if not specified, ignore + $compareContentStream = in_array('content stream', $payloadTable->getRow(0), true); + foreach ($workspaces as $workspace) { $actualComparableHash[] = array_map(json_encode(...), [ 'name' => $workspace->workspaceName, 'base workspace' => $workspace->baseWorkspaceName, 'status' => $workspace->status, - 'content stream' => $workspace->currentContentStreamId, - 'publishable changes' => $workspace->hasPublishableChanges() + ...($compareContentStream ? ['content stream' => $workspace->currentContentStreamId] : []), + ...['publishable changes' => $workspace->hasPublishableChanges()] ]); } - Assert::assertSame($payloadTable->getHash(), $actualComparableHash); + // ensure the check result does not depend on the workspace ordering + $expected = $payloadTable->getHash(); + usort($expected, fn($a, $b) => $a['name'] <=> $b['name']); + usort($actualComparableHash, fn($a, $b) => $a['name'] <=> $b['name']); + Assert::assertSame($expected, $actualComparableHash); } /** diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index c9b6a0eeec5..fbbbba478fb 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -15,6 +15,7 @@ namespace Neos\Neos\Command; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; @@ -26,6 +27,7 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; +use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -56,6 +58,9 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected Now $now; + /** * Publish changes of a workspace * @@ -524,6 +529,38 @@ public function showCommand(string $workspace, string $contentRepository = 'defa ]); } + /** + * Removes all stale personal workspaces. + * + * A personal workspace is considered stale if it has no pending changes, no other workspace uses it as a base + * workspace and the owner of the workspace did not log in for the time specified. + * + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @param string $dateInterval The time interval a user had to be inactive for its workspaces to be considered stale. (Default: '7 days') + */ + public function removeStaleCommand(string $contentRepository = 'default', string $dateInterval = '7 days'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $interval = \DateInterval::createFromDateString($dateInterval); + if ($interval === false) { + $this->outputLine('Unable to parse date interval "%s".', [$dateInterval]); + $this->quit(); + } + + $ownerUserNotLoggedInAfter = $this->now->sub($interval); + if ($this->now <= $ownerUserNotLoggedInAfter) { + $this->outputLine('Date interval must not be negative or zero "%s".', [$dateInterval]); + $this->quit(); + } + + $staleWorkspaceNames = $this->workspaceService->getStalePersonalWorkspaceNames($contentRepositoryId, $ownerUserNotLoggedInAfter); + $this->outputLine('Found %d stale workspaces for users not logged in after %s.', [count($staleWorkspaceNames), $ownerUserNotLoggedInAfter->format('Y-m-d')]); + + $this->workspaceService->deleteStalePersonalWorkspaces($contentRepositoryId, $staleWorkspaceNames); + } + // ----------------------- private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject diff --git a/Neos.Neos/Classes/Domain/Model/UserIds.php b/Neos.Neos/Classes/Domain/Model/UserIds.php new file mode 100644 index 00000000000..0e659083c6c --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/UserIds.php @@ -0,0 +1,45 @@ + + */ +final readonly class UserIds implements \IteratorAggregate +{ + /** @param array $items */ + private function __construct( + private array $items + ) { + } + + public static function create( + UserId ...$items + ): self { + $indexed = []; + foreach ($items as $item) { + $indexed[$item->value] = $item; + } + return new self( + $indexed + ); + } + + public function contain(UserId $userId): bool + { + return array_key_exists($userId->value, $this->items); + } + + /** @return list */ + public function toStringArray(): array + { + return array_keys($this->items); + } + + public function getIterator(): \Traversable + { + yield from array_values($this->items); + } +} diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php index df4ead8c822..a0b2d717a36 100644 --- a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php +++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php @@ -385,6 +385,30 @@ classification = :personalWorkspaceClassification } } + /** + * @return \Traversable + */ + public function findAllPersonalWorkspaceNamesByContentRepositoryId(ContentRepositoryId $contentRepositoryId): \Traversable + { + $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAllAssociative($query, [ + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, + 'contentRepositoryId' => $contentRepositoryId->value, + ]); + foreach ($rows as $row) { + yield UserId::fromString($row['owner_user_id']) => WorkspaceName::fromString($row['workspace_name']); + } + } + /** * @param \Closure(): void $fn * @return void diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index 9c6f9e52348..4fe34de4fe0 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -39,6 +39,7 @@ use Neos\Neos\Domain\Exception; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; +use Neos\Neos\Domain\Model\UserIds; use Neos\Neos\Domain\Repository\UserRepository; use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Party\Domain\Model\AbstractParty; @@ -775,6 +776,28 @@ public function getAllRoles(User $user): array return $roles; } + public function findUserIdsNotLoggedInAfter(\DateTimeInterface $dateTime): UserIds + { + $userIds = []; + /** @var User $user */ + foreach ($this->getUsers() as $user) { + $accounts = $user->getAccounts(); + $loggedIn = false; + foreach ($accounts as $account) { + $lastSuccessfulAuthenticationDate = $account->getLastSuccessfulAuthenticationDate(); + if ($lastSuccessfulAuthenticationDate != null && $lastSuccessfulAuthenticationDate > $dateTime) { + $loggedIn = true; + break; + } + } + + if (!$loggedIn) { + $userIds[] = $user->getId(); + } + } + return UserIds::create(...$userIds); + } + /** * @param User $user * @param bool $keepCurrentSession diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index e9f7f92b2f4..970f05a681f 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -24,6 +24,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceNames; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; @@ -107,7 +108,8 @@ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentReposito if ($workspaceName === null) { throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); } - return $this->requireWorkspace($contentRepositoryId, $workspaceName); + + return $this->recreateStaleWorkspaceIfMissing($contentRepositoryId, $workspaceName); } /** @@ -152,14 +154,7 @@ public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId if ($existingUserWorkspace !== null) { throw new \RuntimeException(sprintf('Failed to create personal workspace "%s" for user with id "%s", because the workspace "%s" is already assigned to the user', $workspaceName->value, $ownerId->value, $existingUserWorkspace->value), 1733754904); } - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspaceName, - ContentStreamId::create() - ) - ); + $this->createPersonalWorkspaceWithoutMetadata($contentRepositoryId, $workspaceName, $baseWorkspaceName); $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::PERSONAL, $ownerId); } @@ -203,7 +198,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con { $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId()); if ($existingWorkspaceName !== null) { - $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); + $this->recreateStaleWorkspaceIfMissing($contentRepositoryId, $existingWorkspaceName); return; } $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); @@ -262,6 +257,29 @@ public function deleteWorkspace(ContentRepositoryId $contentRepositoryId, Worksp $this->softRemovalGarbageCollector->run($contentRepositoryId); } + /** + * Cleans up stale user workspaces - in combination with {@see getStalePersonalWorkspaceNames}. + * Any metadata and roles attached to the personal workspaces are kept. + * To recreate the cores workspace {@see createPersonalWorkspaceForUserIfMissing} must be used. + */ + public function deleteStalePersonalWorkspaces(ContentRepositoryId $contentRepositoryId, WorkspaceNames $workspaceNames): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + + foreach ($workspaceNames as $workspaceName) { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + + // We keep the Neos workspace metadata and roles for the time the workspace is recreated. + $contentRepository->handle( + DeleteWorkspace::create( + $workspaceName + ) + ); + } + + $this->softRemovalGarbageCollector->run($contentRepositoryId); + } + /** * Get all role assignments for the specified workspace * @@ -301,7 +319,58 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } + public function getStalePersonalWorkspaceNames(ContentRepositoryId $contentRepositoryId, \DateTimeImmutable $ownerUserNotLoggedInAfter): WorkspaceNames + { + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaces = $contentRepositoryInstance->findWorkspaces(); + $probablyStaleWorkspaceNames = WorkspaceNames::fromWorkspaces( + $workspaces->filter( + fn($workspace) => !$workspace->hasPublishableChanges() && + $workspaces->getDependantWorkspacesRecursively($workspace->workspaceName)->isEmpty() + ) + ); + + $inactiveUserIds = $this->userService->findUserIdsNotLoggedInAfter($ownerUserNotLoggedInAfter); + + $personalWorkspaces = $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepositoryId); + $staleWorkspaceNames = []; + foreach ($personalWorkspaces as $userId => $personalWorkspace) { + if ( + $probablyStaleWorkspaceNames->contain($personalWorkspace) + && $inactiveUserIds->contain($userId) + ) { + $staleWorkspaceNames[] = $personalWorkspace; + } + } + return WorkspaceNames::create(...$staleWorkspaceNames); + } + // ------------------ + private function recreateStaleWorkspaceIfMissing(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace + { + try { + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } catch (WorkspaceDoesNotExist $e) { + //TODO: how to restore the correct baseWorkspaceName? What if it does not exist anymore? Should it be restored at all? + $this->createPersonalWorkspaceWithoutMetadata($contentRepositoryId, $workspaceName, WorkspaceName::forLive()); + } + + // do not catch again, if it fails now something is definitely wrong + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } + + private function createPersonalWorkspaceWithoutMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $baseWorkspaceName): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + ContentStreamId::create() + ) + ); + } /** * @throws WorkspaceDoesNotExist if the workspace does not exist diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 6c62dd6065b..f48a026c817 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -20,6 +20,7 @@ use Neos\Neos\Domain\Service\UserService; use Neos\Party\Domain\Model\PersonName; use Neos\Utility\ObjectAccess; +use PHPUnit\Framework\Assert; /** * Step implementations for UserService related tests inside Neos.Neos @@ -72,6 +73,34 @@ public function theFollowingNeosUsersExist(TableNode $usersTable): void } } + /** + * @When Neos user :username last logged in :days days ago + */ + public function neosUserLastLoggedInDaysAgo(string $username, int $days): void + { + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + $lastLoginDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); + $user->getAccounts()->map(function($account) use ($lastLoginDate) { + $refLastSuccessfulAuthenticationDate = new ReflectionProperty(\Neos\Flow\Security\Account::class, 'lastSuccessfulAuthenticationDate'); + $refLastSuccessfulAuthenticationDate->setAccessible(true); + $refLastSuccessfulAuthenticationDate->setValue($account, $lastLoginDate); + }); + $userService->updateUser($user); + } + + /** + * @Then the following users did not log in within :days days: + */ + public function theFollowingUsersDidNotLogInWithinXDays(int $days, TableNode $usersTable): void + { + $userService = $this->getObject(UserService::class); + $cutoffDate = (new \DateTime())->sub(new \DateInterval('P' . $days . 'D')); + $userIds = $userService->findUserIdsNotLoggedInAfter($cutoffDate); + + Assert::assertEqualsCanonicalizing(array_column($usersTable->getColumnsHash(), 'Id'), $userIds->toStringArray()); + } + private function createUser(string $username, ?string $firstName = null, ?string $lastName = null, ?array $roleIdentifiers = null, ?string $id = null): void { $userService = $this->getObject(UserService::class); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 25c910675c9..1eb14230c74 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -14,10 +14,12 @@ use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRBehavioralTestsSubjectProvider; +use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -50,9 +52,13 @@ trait WorkspaceServiceTrait abstract private function getObject(string $className): object; /** - * @BeforeScenario + * Cleanup _after_ scenario. If cleaned up with `BeforeScenario` the workspaces from the last scenario remain in the + * database. The next test run will not clean them up before the first scenario since no content repositories are + * already set up at that moment. + * + * @AfterScenario */ - final public function pruneWorkspaceService(): void + final public function pruneWorkspaceService($param): void { foreach (static::$alreadySetUpContentRepositories as $contentRepositoryId) { $this->getObject(\Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository::class)->pruneWorkspaceMetadata($contentRepositoryId); @@ -359,6 +365,31 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, Assert::assertFalse($permissions->manage); } + /** + * @Then the following stale workspaces exist in content repository :contentRepositoryId: + */ + public function theFollowingStaleWorkspacesExistInContentRepository(string $contentRepositoryId, TableNode $workspacesNames): void + { + $actualWorkspaceNames = $this->getObject(WorkspaceService::class)->getStalePersonalWorkspaceNames( + ContentRepositoryId::fromString($contentRepositoryId), + $this->getObject(Now::class)->sub(new DateInterval('P7D')) + ); + + Assert::assertEqualsCanonicalizing(array_column($workspacesNames->getColumnsHash(), 'WorkspaceName'), $actualWorkspaceNames->toStringArray()); + } + + /** + * @Then the stale workspaces are deleted in content repository :contentRepositoryId + */ + public function theStaleWorkspacesAreDeletedInContentRepository(string $contentRepositoryId): void + { + $workspacesNames = $this->getObject(WorkspaceService::class)->getStalePersonalWorkspaceNames( + ContentRepositoryId::fromString($contentRepositoryId), + $this->getObject(Now::class)->sub(new DateInterval('P7D')) + ); + $this->getObject(WorkspaceService::class)->deleteStalePersonalWorkspaces(ContentRepositoryId::fromString($contentRepositoryId), $workspacesNames); + } + private function userIdForUsername(string $username): UserId { $user = $this->getObject(UserService::class)->getUser($username); diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index c91c1a3f3b6..a0556a29939 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -6,6 +6,7 @@ Feature: Neos WorkspaceService related features And using the following node types: """yaml 'Neos.ContentRepository:Root': {} + 'Neos.ContentRepository.Testing:Node': {} """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -291,3 +292,87 @@ Feature: Neos WorkspaceService related features Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" + + Scenario: Personal Workspaces without change and without user login within 7 days are stale + When the root workspace "some-root-workspace" is created + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And Neos user "jane.doe" last logged in 9 days ago + And the personal workspace "johndoe-user-workspace" is created with the target workspace "some-root-workspace" for user "john.doe" + And Neos user "john.doe" last logged in 8 days ago + And the personal workspace "editor-user-workspace" is created with the target workspace "some-root-workspace" for user "editor" + And Neos user "editor" last logged in 5 days ago + + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "janedoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "johndoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | + | janedoe-user-workspace | + | johndoe-user-workspace | + + And the stale workspaces are deleted in content repository "default" + Then I expect the workspace "janedoe-user-workspace" to not exist + Then I expect the workspace "johndoe-user-workspace" to not exist + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + + When a personal workspace for user "jane.doe" is created + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "editor-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "janedoe-user-workspace" | "some-root-workspace" | "UP_TO_DATE" | false | + | "some-root-workspace" | null | "UP_TO_DATE" | false | + + Scenario: Personal Workspaces recreation works if the base workspace is "live" + When the root workspace "live" is created + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "live" for user "jane.doe" + And Neos user "jane.doe" last logged in 9 days ago + + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "janedoe-user-workspace" | "live" | "UP_TO_DATE" | false | + | "live" | null | "UP_TO_DATE" | false | + + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | + | janedoe-user-workspace | + + And the stale workspaces are deleted in content repository "default" + Then I expect the workspace "janedoe-user-workspace" to not exist + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "live" | null | "UP_TO_DATE" | false | + + When a personal workspace for user "jane.doe" is created + And I expect the following workspaces to exist: + | name | base workspace | status | publishable changes | + | "janedoe-user-workspace" | "live" | "UP_TO_DATE" | false | + | "live" | null | "UP_TO_DATE" | false | + + Scenario: Workspaces with changes are not stale + Given the root workspace "some-root-workspace" is created + And I am in workspace "some-root-workspace" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the personal workspace "janedoe-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And I am in workspace "janedoe-user-workspace" + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:Node | {} | + And Neos user "jane.doe" last logged in 9 days ago + + Then the following stale workspaces exist in content repository "default": + | WorkspaceName | diff --git a/Neos.Neos/Tests/Behavior/Features/User/UserService.feature b/Neos.Neos/Tests/Behavior/Features/User/UserService.feature new file mode 100644 index 00000000000..01430b5f924 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/User/UserService.feature @@ -0,0 +1,33 @@ +@flowEntities +Feature: Neos UserService related features + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | editor | Edward | Editor | Neos.Neos:Editor | + + Scenario: List user accounts not logged in for some time + When Neos user "jane.doe" last logged in 9 days ago + And Neos user "john.doe" last logged in 6 days ago + And Neos user "editor" last logged in 5 days ago + Then the following users did not log in within 7 days: + | Id | + | janedoe | + And the following users did not log in within 6 days: + | Id | + | janedoe | + | johndoe | + And the following users did not log in within 3 days: + | Id | + | janedoe | + | johndoe | + | editor | diff --git a/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php b/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php new file mode 100644 index 00000000000..6cd0cf5790a --- /dev/null +++ b/Neos.Neos/Tests/Unit/Domain/Model/UserIdsTest.php @@ -0,0 +1,27 @@ +contain($nameInstance)); + self::assertTrue($workspaceNames->contain(UserId::fromString('foo'))); + self::assertFalse($workspaceNames->contain(UserId::fromString('not-included'))); + } +}