diff --git a/lib/AuditLog/Operation.php b/lib/AuditLog/Operation.php new file mode 100644 index 000000000..ecb4c34e2 --- /dev/null +++ b/lib/AuditLog/Operation.php @@ -0,0 +1,67 @@ + 'A team was created', + self::TeamDeleted => 'A team was deleted', + self::TeamRenamed => 'A team was renamed', + self::TeamConfigUpdated => 'The configuration flags of a team (open, invite only, visible, federated, ...) were changed', + self::TeamSettingUpdated => 'A setting of a team, for example the password of its shares, was set or removed', + self::InvitationCreated => 'An invitation link to join a team was created', + self::InvitationRevoked => 'The invitation link to join a team was revoked', + self::MemberAdded => 'A member was added to a team, or a request to join it was accepted', + self::MemberInvited => 'A user, group or team was invited to join an invite only team', + self::MemberRemoved => 'A member was removed from a team', + self::MemberLevelUpdated => 'The level of a member of a team (member, moderator, admin, owner) was changed, including ownership transfers', + self::MemberJoined => 'A user joined an open team, through an invitation link or by accepting an invitation', + self::MemberRequested => 'A user requested to join a team that requires confirmation', + self::MemberLeft => 'A member left a team', + self::RemoteInstanceAdded => 'A remote instance was added for federated teams, with the type defining how much it is trusted', + self::RemoteInstanceUpdated => 'The identity or the type of a known remote instance for federated teams was changed', + self::RemoteInstanceRemoved => 'A remote instance for federated teams was removed', + self::ScimInstanceAdded => 'A remote instance was allowed to add members to teams managed by SCIM', + self::ScimInstanceRemoved => 'A remote instance was no longer allowed to add members to teams managed by SCIM', + self::ConfigUpdated => 'An admin setting of Teams (federated teams, frontal address, team folder quotas) was changed', + }; + } +} diff --git a/lib/Command/CirclesRemote.php b/lib/Command/CirclesRemote.php index 65aab420b..85cf13151 100644 --- a/lib/Command/CirclesRemote.php +++ b/lib/Command/CirclesRemote.php @@ -94,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($host) { if ($input->getOption('delete')) { - $this->remoteRequest->deleteInstance($host); + $this->remoteStreamService->removeRemoteInstance($host); return 0; } else { $this->requestInstance($host); @@ -299,7 +299,7 @@ private function saveRemote(RemoteInstance $remoteSignatory) { if (!$this->interfaceService->isInterfaceInternal($remoteSignatory->getInterface())) { $remoteSignatory->setAliases([]); } - $this->remoteRequest->save($remoteSignatory); + $this->remoteStreamService->saveRemoteInstance($remoteSignatory); $this->output->writeln('remote instance saved'); } } diff --git a/lib/Command/CirclesScimAddRemoteInstance.php b/lib/Command/CirclesScimAddRemoteInstance.php index 879d56644..a92eccafc 100644 --- a/lib/Command/CirclesScimAddRemoteInstance.php +++ b/lib/Command/CirclesScimAddRemoteInstance.php @@ -10,8 +10,10 @@ namespace OCA\Circles\Command; use OC\Core\Command\Base; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\RemoteRequest; +use OCA\Circles\Service\AuditLogService; use OCP\AppFramework\Services\IAppConfig; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -21,6 +23,7 @@ class CirclesScimAddRemoteInstance extends Base { public function __construct( private readonly IAppConfig $appConfig, private readonly RemoteRequest $remoteRequest, + private readonly AuditLogService $auditLogService, ) { parent::__construct(); } @@ -55,6 +58,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $instances[] = $instance; $this->appConfig->setAppValueArray(ConfigLexicon::SCIM_REMOTE_INSTANCES, $instances); + $this->auditLogService->log(Operation::ScimInstanceAdded, 'Remote instance "%s" was allowed to add members to SCIM teams', [ + 'instance' => $instance, + ]); $output->writeln('done'); diff --git a/lib/Command/CirclesScimRemoveRemoteInstance.php b/lib/Command/CirclesScimRemoveRemoteInstance.php index 1bf3b3ba4..bb6af5fc0 100644 --- a/lib/Command/CirclesScimRemoveRemoteInstance.php +++ b/lib/Command/CirclesScimRemoveRemoteInstance.php @@ -10,7 +10,9 @@ namespace OCA\Circles\Command; use OC\Core\Command\Base; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\ConfigLexicon; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ScimService; use OCP\AppFramework\Services\IAppConfig; use Symfony\Component\Console\Input\InputArgument; @@ -21,6 +23,7 @@ class CirclesScimRemoveRemoteInstance extends Base { public function __construct( private readonly IAppConfig $appConfig, private readonly ScimService $scimService, + private readonly AuditLogService $auditLogService, ) { parent::__construct(); } @@ -46,6 +49,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int unset($instances[$key]); $this->appConfig->setAppValueArray(ConfigLexicon::SCIM_REMOTE_INSTANCES, array_values($instances)); + $this->auditLogService->log(Operation::ScimInstanceRemoved, 'Remote instance "%s" is no longer allowed to add members to SCIM teams', [ + 'instance' => $instance, + ]); $this->scimService->removeRemoteInstanceMembers($instance); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 84a066cc4..8d569b484 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -9,7 +9,9 @@ namespace OCA\Circles\Controller; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\ConfigLexicon; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ConfigService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; @@ -24,6 +26,7 @@ public function __construct( IRequest $request, private readonly IAppConfig $appConfig, private readonly IGroupManager $groupManager, + private readonly AuditLogService $auditLogService, ) { parent::__construct($appName, $request); } @@ -31,6 +34,7 @@ public function __construct( public function setValue(string $key, string $value): DataResponse { if ($key === ConfigLexicon::FEDERATED_TEAMS_FRONTAL) { if ($this->setFrontalValue($value)) { + $this->logSetting($key, $value); return $this->getValues(); } @@ -39,6 +43,7 @@ public function setValue(string $key, string $value): DataResponse { if ($key === ConfigLexicon::FEDERATED_TEAMS_ENABLED) { $this->appConfig->setAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED, $value === 'yes'); + $this->logSetting($key, $value === 'yes' ? 'yes' : 'no'); return $this->getValues(); } @@ -48,6 +53,7 @@ public function setValue(string $key, string $value): DataResponse { } $this->appConfig->setAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, (int)$value); + $this->logSetting($key, (string)(int)$value); return $this->getValues(); } @@ -58,6 +64,7 @@ public function setValue(string $key, string $value): DataResponse { } $this->appConfig->setAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, $quotas); + $this->logSetting($key, (string)json_encode($quotas)); return $this->getValues(); } @@ -83,6 +90,13 @@ public function getGroups(string $search = ''): DataResponse { )); } + private function logSetting(string $key, string $value): void { + $this->auditLogService->log(Operation::ConfigUpdated, 'Teams setting "%s" was set to "%s"', [ + 'key' => $key, + 'value' => $value, + ]); + } + /** @param array $quotas */ private function areValidGroupQuotas(array $quotas): bool { foreach ($quotas as $groupId => $quota) { diff --git a/lib/Db/RemoteRequest.php b/lib/Db/RemoteRequest.php index a0f2f73f1..dda28e24b 100644 --- a/lib/Db/RemoteRequest.php +++ b/lib/Db/RemoteRequest.php @@ -242,10 +242,11 @@ public function searchDuplicate(RemoteInstance $remoteInstance): RemoteInstance /** * @param RemoteInstance $remoteInstance */ - public function deleteInstance(string $host) { + public function deleteInstance(string $host): int { $qb = $this->getRemoteDeleteSql(); $qb->limitToInstance($host); - $qb->executeStatement(); + + return $qb->executeStatement(); } /** diff --git a/lib/FederatedItems/CircleConfig.php b/lib/FederatedItems/CircleConfig.php index 3f0115193..b248dacf4 100644 --- a/lib/FederatedItems/CircleConfig.php +++ b/lib/FederatedItems/CircleConfig.php @@ -9,6 +9,7 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Exceptions\FederatedItemBadRequestException; use OCA\Circles\Exceptions\FederatedItemException; @@ -17,6 +18,7 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\PermissionService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -36,11 +38,13 @@ class CircleConfig implements * @param CircleRequest $circleRequest * @param PermissionService $permissionService * @param ConfigService $configService + * @param AuditLogService $auditLogService */ public function __construct( private CircleRequest $circleRequest, private PermissionService $permissionService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { } @@ -146,6 +150,17 @@ public function manage(FederatedEvent $event): void { // TODO: sync if it is to broadcast to Trusted RemoteInstance $this->circleRequest->updateConfig($circle); + + $oldConfig = $event->getCircle()->getConfig(); + if ($config !== $oldConfig) { + $this->auditLogService->logTeamChange($circle, Operation::TeamConfigUpdated, 'Configuration of team %s ("%s") was changed from %s to %s by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'oldConfig' => $oldConfig, + 'config' => $config, + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); + } } /** diff --git a/lib/FederatedItems/CircleCreate.php b/lib/FederatedItems/CircleCreate.php index 594544953..8cf9c510b 100644 --- a/lib/FederatedItems/CircleCreate.php +++ b/lib/FederatedItems/CircleCreate.php @@ -10,6 +10,7 @@ namespace OCA\Circles\FederatedItems; use OC\User\NoUserException; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\CircleNotFoundException; @@ -22,6 +23,7 @@ use OCA\Circles\IFederatedItemHighSeverity; use OCA\Circles\IFederatedItemMustBeInitializedLocally; use OCA\Circles\Model\Federated\FederatedEvent; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\CircleService; use OCA\Circles\Service\EventService; use OCA\Circles\Service\MaintenanceService; @@ -47,6 +49,7 @@ public function __construct( private MembershipService $membershipService, private MaintenanceService $maintenanceService, private EventService $eventService, + private AuditLogService $auditLogService, ) { } @@ -101,6 +104,13 @@ public function manage(FederatedEvent $event): void { } $this->eventService->circleCreating($event); + + $this->auditLogService->logTeamChange($circle, Operation::TeamCreated, 'Team %s ("%s") was created with owner "%s" by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'owner' => $this->auditLogService->getUserId($owner), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/FederatedItems/CircleCreateInvitation.php b/lib/FederatedItems/CircleCreateInvitation.php index 62abceafc..e5e5eb8d5 100644 --- a/lib/FederatedItems/CircleCreateInvitation.php +++ b/lib/FederatedItems/CircleCreateInvitation.php @@ -9,11 +9,13 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Entity\CircleInvitation; use OCA\Circles\IFederatedItem; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Repository\CircleInvitationRepository; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\EventService; use OCA\Circles\Tools\Traits\TDeserialize; use OCP\Security\ISecureRandom; @@ -25,6 +27,7 @@ class CircleCreateInvitation implements IFederatedItem { public function __construct( private CircleInvitationRepository $circleInvitationRepository, private EventService $eventService, + private AuditLogService $auditLogService, ) { } @@ -56,6 +59,14 @@ public function manage(FederatedEvent $event): void { // todo: do we need separate event here? $this->eventService->circleEditing($event); + + // never logs the invitation code, it lets anyone join the team + $circle = $event->getCircle(); + $this->auditLogService->logTeamChange($circle, Operation::InvitationCreated, 'Invitation link of team %s ("%s") was created by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } public function result(FederatedEvent $event, array $results): void { diff --git a/lib/FederatedItems/CircleDestroy.php b/lib/FederatedItems/CircleDestroy.php index 6f9df62fb..e9508c5c2 100644 --- a/lib/FederatedItems/CircleDestroy.php +++ b/lib/FederatedItems/CircleDestroy.php @@ -9,6 +9,7 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\FederatedItemBadRequestException; @@ -20,6 +21,7 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\EventService; use OCA\Circles\Service\MembershipService; use OCA\Circles\StatusCode; @@ -46,12 +48,14 @@ class CircleDestroy implements * @param MemberRequest $memberRequest * @param EventService $eventService * @param MembershipService $membershipService + * @param AuditLogService $auditLogService */ public function __construct( private CircleRequest $circleRequest, private MemberRequest $memberRequest, private EventService $eventService, private MembershipService $membershipService, + private AuditLogService $auditLogService, ) { } @@ -96,6 +100,12 @@ public function manage(FederatedEvent $event): void { foreach ($parentCirlces as $parentCircle) { $this->membershipService->updatePopulation($parentCircle); } + + $this->auditLogService->logTeamChange($circle, Operation::TeamDeleted, 'Team %s ("%s") was deleted by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/FederatedItems/CircleEdit.php b/lib/FederatedItems/CircleEdit.php index d6c43e143..f262d9cf2 100644 --- a/lib/FederatedItems/CircleEdit.php +++ b/lib/FederatedItems/CircleEdit.php @@ -9,6 +9,7 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\CircleNameTooLongException; @@ -17,6 +18,7 @@ use OCA\Circles\IFederatedItem; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\CircleService; use OCA\Circles\Service\EventService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -36,12 +38,14 @@ class CircleEdit implements IFederatedItem { * @param MemberRequest $memberRequest * @param CircleService $circleService * @param EventService $eventService + * @param AuditLogService $auditLogService */ public function __construct( private CircleRequest $circleRequest, private MemberRequest $memberRequest, private CircleService $circleService, private EventService $eventService, + private AuditLogService $auditLogService, ) { } @@ -114,6 +118,17 @@ public function manage(FederatedEvent $event): void { $this->circleRequest->edit($circle); $this->memberRequest->updateDisplayName($circle->getSingleId(), $circle->getDisplayName()); $this->eventService->circleEditing($event); + + $oldName = $this->auditLogService->getTeamName($event->getCircle()); + $name = $this->auditLogService->getTeamName($circle); + if ($name !== $oldName) { + $this->auditLogService->logTeamChange($circle, Operation::TeamRenamed, 'Team %s was renamed from "%s" to "%s" by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'oldName' => $oldName, + 'name' => $name, + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); + } } /** diff --git a/lib/FederatedItems/CircleJoin.php b/lib/FederatedItems/CircleJoin.php index b12238d8d..e27d7c98f 100644 --- a/lib/FederatedItems/CircleJoin.php +++ b/lib/FederatedItems/CircleJoin.php @@ -10,6 +10,7 @@ namespace OCA\Circles\FederatedItems; use Exception; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\FederatedItemBadRequestException; use OCA\Circles\Exceptions\FederatedUserException; @@ -29,6 +30,7 @@ use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\ManagedModel; use OCA\Circles\Model\Member; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\CircleService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\EventService; @@ -61,6 +63,7 @@ class CircleJoin implements * @param MembershipService $membershipService * @param EventService $eventService * @param ConfigService $configService + * @param AuditLogService $auditLogService */ public function __construct( private IUserManager $userManager, @@ -71,6 +74,7 @@ public function __construct( private MembershipService $membershipService, private EventService $eventService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { } @@ -191,6 +195,17 @@ public function manage(FederatedEvent $event): void { } $this->membershipService->updatePopulation($event->getCircle()); + + $circle = $event->getCircle(); + $parameters = $this->auditLogService->getMemberParameters($member) + [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + ]; + if ($member->getStatus() === Member::STATUS_REQUEST) { + $this->auditLogService->logTeamChange($circle, Operation::MemberRequested, 'Member "%s" (%s, member %s) requested to join team %s ("%s")', $parameters); + } else { + $this->auditLogService->logTeamChange($circle, Operation::MemberJoined, 'Member "%s" (%s, member %s) joined team %s ("%s")', $parameters); + } } /** diff --git a/lib/FederatedItems/CircleLeave.php b/lib/FederatedItems/CircleLeave.php index b82ca073f..7e288a6c1 100644 --- a/lib/FederatedItems/CircleLeave.php +++ b/lib/FederatedItems/CircleLeave.php @@ -10,6 +10,7 @@ namespace OCA\Circles\FederatedItems; use OCA\Circles\AppInfo\Application; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\CircleNotFoundException; @@ -26,6 +27,7 @@ use OCA\Circles\Model\FederatedUser; use OCA\Circles\Model\Member; use OCA\Circles\Model\Probes\CircleProbe; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\EventService; use OCA\Circles\Service\MembershipService; @@ -62,6 +64,7 @@ public function __construct( private MembershipService $membershipService, private EventService $eventService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { $this->setup('app', Application::APP_ID); } @@ -128,8 +131,11 @@ public function verify(FederatedEvent $event): void { public function manage(FederatedEvent $event): void { $member = $event->getMember(); $newOwnerId = $event->getData()->g('newOwnerId'); + $newOwner = null; + $newOwnerOldLevel = Member::LEVEL_NONE; if ($newOwnerId !== '') { $newOwner = $this->memberRequest->getMemberById($newOwnerId); + $newOwnerOldLevel = $newOwner->getLevel(); $newOwner->setLevel(Member::LEVEL_OWNER); $this->memberRequest->updateLevel($newOwner); $this->membershipService->onUpdate($newOwner->getSingleId()); @@ -152,6 +158,30 @@ public function manage(FederatedEvent $event): void { } $this->membershipService->updatePopulation($event->getCircle()); + + $this->logLeave($event->getCircle(), $member, $newOwner, $newOwnerOldLevel, $destroyingCircle); + } + + private function logLeave(Circle $circle, Member $member, ?Member $newOwner, int $newOwnerOldLevel, bool $destroyingCircle): void { + $teamParameters = [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + ]; + + $this->auditLogService->logTeamChange($circle, Operation::MemberLeft, 'Member "%s" (%s, member %s) left team %s ("%s")', $this->auditLogService->getMemberParameters($member) + $teamParameters); + + if ($newOwner !== null) { + $this->auditLogService->logTeamChange($circle, Operation::MemberLevelUpdated, 'Level of member "%s" (%s, member %s) in team %s ("%s") was changed from %s to %s because the owner left', $this->auditLogService->getMemberParameters($newOwner) + $teamParameters + [ + 'oldLevel' => $this->auditLogService->getLevelName($newOwnerOldLevel), + 'level' => $this->auditLogService->getLevelName(Member::LEVEL_OWNER), + ]); + } + + if ($destroyingCircle) { + $this->auditLogService->logTeamChange($circle, Operation::TeamDeleted, 'Team %s ("%s") was deleted because its last member "%s" left', $teamParameters + [ + 'userId' => $this->auditLogService->getUserId($member), + ]); + } } /** diff --git a/lib/FederatedItems/CircleRevokeInvitation.php b/lib/FederatedItems/CircleRevokeInvitation.php index a49f0d87e..c4a37b54b 100644 --- a/lib/FederatedItems/CircleRevokeInvitation.php +++ b/lib/FederatedItems/CircleRevokeInvitation.php @@ -9,10 +9,12 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\IFederatedItem; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Repository\CircleInvitationRepository; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\EventService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -22,6 +24,7 @@ class CircleRevokeInvitation implements IFederatedItem { public function __construct( private CircleInvitationRepository $circleInvitationRepository, private EventService $eventService, + private AuditLogService $auditLogService, ) { } @@ -40,9 +43,17 @@ public function verify(FederatedEvent $event): void { public function manage(FederatedEvent $event): void { $circle = clone $event->getCircle(); - $this->circleInvitationRepository->deleteBy(['circleId' => $circle->getSingleId()]); + $deleted = $this->circleInvitationRepository->deleteBy(['circleId' => $circle->getSingleId()]); // todo: do we need separate event here? $this->eventService->circleEditing($event); + + if ($deleted > 0) { + $this->auditLogService->logTeamChange($circle, Operation::InvitationRevoked, 'Invitation link of team %s ("%s") was revoked by "%s"', [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); + } } public function result(FederatedEvent $event, array $results): void { diff --git a/lib/FederatedItems/CircleSetting.php b/lib/FederatedItems/CircleSetting.php index a20f02c79..6565aa5a7 100644 --- a/lib/FederatedItems/CircleSetting.php +++ b/lib/FederatedItems/CircleSetting.php @@ -9,12 +9,14 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\CircleRequest; use OCA\Circles\IFederatedItem; use OCA\Circles\IFederatedItemAsyncProcess; use OCA\Circles\IFederatedItemHighSeverity; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\ShareTokenService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -29,6 +31,7 @@ public function __construct( private CircleRequest $circleRequest, private ShareTokenService $shareTokenService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { } @@ -108,6 +111,17 @@ public function manage(FederatedEvent $event): void { && !$this->configService->enforcePasswordOnSharedFile($circle)) { $this->shareTokenService->removeSharePassword($circle->getSingleId()); } + + // the value is never logged, it can be the password of the shares + $message = $event->getParams()->gBool('unset') + ? 'Setting "%s" of team %s ("%s") was removed by "%s"' + : 'Setting "%s" of team %s ("%s") was set by "%s"'; + $this->auditLogService->logTeamChange($circle, Operation::TeamSettingUpdated, $message, [ + 'setting' => $event->getParams()->g('setting'), + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/FederatedItems/MassiveMemberAdd.php b/lib/FederatedItems/MassiveMemberAdd.php index d12b07cf9..692688ebc 100644 --- a/lib/FederatedItems/MassiveMemberAdd.php +++ b/lib/FederatedItems/MassiveMemberAdd.php @@ -77,6 +77,8 @@ public function manage(FederatedEvent $event): void { } else { $this->eventService->memberAdding($event); } + + $this->logMemberAdded($event->getCircle(), $member); } catch (Exception) { } } diff --git a/lib/FederatedItems/MemberLevel.php b/lib/FederatedItems/MemberLevel.php index 888db5c83..d70e1ba66 100644 --- a/lib/FederatedItems/MemberLevel.php +++ b/lib/FederatedItems/MemberLevel.php @@ -9,6 +9,7 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\FederatedItemBadRequestException; use OCA\Circles\Exceptions\FederatedItemException; @@ -21,6 +22,7 @@ use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Model\Member; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\EventService; use OCA\Circles\Service\MembershipService; @@ -44,12 +46,14 @@ class MemberLevel implements * @param MembershipService $membershipService * @param EventService $eventService * @param ConfigService $configService + * @param AuditLogService $auditLogService */ public function __construct( private MemberRequest $memberRequest, private MembershipService $membershipService, private EventService $eventService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { } @@ -104,6 +108,7 @@ public function manage(FederatedEvent $event): void { $member = clone $event->getMember(); $member->setLevel($event->getData()->gInt('level')); + $oldOwner = null; if ($member->getLevel() === Member::LEVEL_OWNER) { $oldOwner = $this->memberRequest->promoteToOwner($member); if ($oldOwner !== null) { @@ -116,6 +121,22 @@ public function manage(FederatedEvent $event): void { $this->membershipService->onUpdate($member->getSingleId()); $this->eventService->memberLevelEditing($event); + + $circle = $event->getCircle(); + $this->logLevelChange($circle, $member, $event->getMember()->getLevel()); + if ($oldOwner !== null && $oldOwner->getId() !== $member->getId()) { + $this->logLevelChange($circle, $oldOwner, Member::LEVEL_OWNER); + } + } + + private function logLevelChange(Circle $circle, Member $member, int $oldLevel): void { + $this->auditLogService->logTeamChange($circle, Operation::MemberLevelUpdated, 'Level of member "%s" (%s, member %s) in team %s ("%s") was changed from %s to %s by "%s"', $this->auditLogService->getMemberParameters($member) + [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'oldLevel' => $this->auditLogService->getLevelName($oldLevel), + 'level' => $this->auditLogService->getLevelName($member->getLevel()), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/FederatedItems/MemberRemove.php b/lib/FederatedItems/MemberRemove.php index f3d4e0463..0f38bab27 100644 --- a/lib/FederatedItems/MemberRemove.php +++ b/lib/FederatedItems/MemberRemove.php @@ -9,6 +9,7 @@ namespace OCA\Circles\FederatedItems; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\RequestBuilderException; @@ -18,6 +19,7 @@ use OCA\Circles\IFederatedItemMemberRequired; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\EventService; use OCA\Circles\Service\MembershipService; @@ -42,6 +44,7 @@ public function __construct( private readonly MemberRequest $memberRequest, private readonly MembershipService $membershipService, private readonly EventService $eventService, + private readonly AuditLogService $auditLogService, ) { } @@ -79,6 +82,13 @@ public function manage(FederatedEvent $event): void { $this->eventService->memberRemoving($event); $this->membershipService->updatePopulation($event->getCircle()); + + $circle = $event->getCircle(); + $this->auditLogService->logTeamChange($circle, Operation::MemberRemoved, 'Member "%s" (%s, member %s) was removed from team %s ("%s") by "%s"', $this->auditLogService->getMemberParameters($member) + [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/FederatedItems/SingleMemberAdd.php b/lib/FederatedItems/SingleMemberAdd.php index 7e122cd20..ec9147ec9 100644 --- a/lib/FederatedItems/SingleMemberAdd.php +++ b/lib/FederatedItems/SingleMemberAdd.php @@ -10,6 +10,7 @@ namespace OCA\Circles\FederatedItems; use OC\User\NoUserException; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\CircleNotFoundException; @@ -42,6 +43,7 @@ use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Model\ManagedModel; use OCA\Circles\Model\Member; +use OCA\Circles\Service\AuditLogService; use OCA\Circles\Service\CircleService; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\EventService; @@ -85,6 +87,7 @@ public function __construct( protected EventService $eventService, protected ConfigService $configService, protected MaintenanceService $maintenanceService, + protected AuditLogService $auditLogService, ) { } @@ -137,6 +140,27 @@ public function manage(FederatedEvent $event): void { } $this->membershipService->updatePopulation($event->getCircle()); + + $this->logMemberAdded($event->getCircle(), $member); + } + + protected function logMemberAdded(Circle $circle, Member $member): void { + $parameters = $this->auditLogService->getMemberParameters($member) + [ + 'circleId' => $circle->getSingleId(), + 'name' => $this->auditLogService->getTeamName($circle), + ]; + + if ($member->getStatus() === Member::STATUS_INVITED) { + $this->auditLogService->logTeamChange($circle, Operation::MemberInvited, 'Member "%s" (%s, member %s) was invited to team %s ("%s") by "%s"', $parameters + [ + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); + return; + } + + $this->auditLogService->logTeamChange($circle, Operation::MemberAdded, 'Member "%s" (%s, member %s) was added to team %s ("%s") as %s by "%s"', $parameters + [ + 'level' => $this->auditLogService->getLevelName($member->getLevel()), + 'initiator' => $this->auditLogService->getInitiator($circle), + ]); } /** diff --git a/lib/Service/AuditLogService.php b/lib/Service/AuditLogService.php new file mode 100644 index 000000000..a6ca4befe --- /dev/null +++ b/lib/Service/AuditLogService.php @@ -0,0 +1,109 @@ + $parameters + */ + public function log(Operation $operation, string $message, array $parameters): void { + $this->eventDispatcher->dispatchTyped( + new CriticalActionPerformedEvent($message, $parameters, false, $operation->value) + ); + } + + /** + * Logs a change of a team, only on the instance owning the team: the other instances + * of a federated team apply the same change when it is broadcast to them. + * + * Teams mirroring a Nextcloud group are not logged, their changes come from the group + * which is already audited. + * + * @param array $parameters + */ + public function logTeamChange(Circle $circle, Operation $operation, string $message, array $parameters): void { + if ($circle->getSource() === Member::TYPE_GROUP || !$this->isOwnedLocally($circle)) { + return; + } + + $this->log($operation, $message, $parameters); + } + + /** + * The user who did the change. Changes are often applied in a background request + * without a user session, so the audit log cannot tell the user otherwise. + */ + public function getInitiator(Circle $circle): ?string { + if (!$circle->hasInitiator()) { + return null; + } + + return $this->getUserId($circle->getInitiator()); + } + + /** + * User, group, team or app ID of a member, with the instance when it is remote. + * Email addresses and contacts are not logged, the member ID identifies them. + */ + public function getUserId(IFederatedUser $user): ?string { + if ($user->getUserType() === Member::TYPE_MAIL || $user->getUserType() === Member::TYPE_CONTACT) { + return null; + } + + return $this->configService->displayFederatedUser($user); + } + + /** + * Parameters identifying a member, for messages starting with `Member "%s" (%s, member %s)` + * + * @return array{userId: ?string, memberType: string, memberId: string} + */ + public function getMemberParameters(Member $member): array { + return [ + 'userId' => $this->getUserId($member), + 'memberType' => Member::$TYPE[$member->getUserType()] ?? (string)$member->getUserType(), + 'memberId' => $member->getId(), + ]; + } + + public function getLevelName(int $level): string { + return Member::$DEF_LEVEL[$level] ?? (string)$level; + } + + public function getTeamName(Circle $circle): string { + return $circle->getDisplayName() !== '' ? $circle->getDisplayName() : $circle->getName(); + } + + private function isOwnedLocally(Circle $circle): bool { + try { + return $this->configService->isLocalInstance($circle->getInstance()); + } catch (OwnerNotFoundException) { + return true; + } + } +} diff --git a/lib/Service/RemoteStreamService.php b/lib/Service/RemoteStreamService.php index 485954d4a..9346d987a 100644 --- a/lib/Service/RemoteStreamService.php +++ b/lib/Service/RemoteStreamService.php @@ -11,6 +11,7 @@ use JsonSerializable; use OCA\Circles\AppInfo\Application; +use OCA\Circles\AuditLog\Operation; use OCA\Circles\Db\RemoteRequest; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\RemoteAlreadyExistsException; @@ -66,6 +67,7 @@ class RemoteStreamService extends NCSignature { * @param RemoteRequest $remoteRequest * @param InterfaceService $interfaceService * @param ConfigService $configService + * @param AuditLogService $auditLogService */ public function __construct( private readonly IConfig $config, @@ -73,6 +75,7 @@ public function __construct( private RemoteRequest $remoteRequest, private InterfaceService $interfaceService, private ConfigService $configService, + private AuditLogService $auditLogService, ) { $this->setup('app', 'circles'); } @@ -392,7 +395,32 @@ public function addRemoteInstance( } catch (RemoteNotFoundException) { } + $this->saveRemoteInstance($remoteInstance); + } + + /** + * Stores a new remote instance, trusted according to its type + * + * @throws RemoteUidException + */ + public function saveRemoteInstance(RemoteInstance $remoteInstance): void { $this->remoteRequest->save($remoteInstance); + + $this->auditLogService->log(Operation::RemoteInstanceAdded, 'Remote instance "%s" was added as %s using the %s interface', [ + 'instance' => $remoteInstance->getInstance(), + 'type' => $remoteInstance->getType(), + 'interface' => $this->getInterfaceName($remoteInstance), + ]); + } + + public function removeRemoteInstance(string $instance): void { + if ($this->remoteRequest->deleteInstance($instance) === 0) { + return; + } + + $this->auditLogService->log(Operation::RemoteInstanceRemoved, 'Remote instance "%s" was removed', [ + 'instance' => $instance, + ]); } /** @@ -554,6 +582,10 @@ public function update(RemoteInstance $remote, string $update = self::UPDATE_DAT switch ($update) { case self::UPDATE_DATA: $this->remoteRequest->update($remote); + $this->auditLogService->log(Operation::RemoteInstanceUpdated, 'Identity of remote instance "%s" was changed, it is known as %s', [ + 'instance' => $remote->getInstance(), + 'type' => $remote->getType(), + ]); break; case self::UPDATE_ITEM: @@ -562,6 +594,10 @@ public function update(RemoteInstance $remote, string $update = self::UPDATE_DAT case self::UPDATE_TYPE: $this->remoteRequest->updateType($remote); + $this->auditLogService->log(Operation::RemoteInstanceUpdated, 'Type of remote instance "%s" was changed to %s', [ + 'instance' => $remote->getInstance(), + 'type' => $remote->getType(), + ]); break; case self::UPDATE_HREF: @@ -573,4 +609,8 @@ public function update(RemoteInstance $remote, string $update = self::UPDATE_DAT break; } } + + private function getInterfaceName(RemoteInstance $remoteInstance): string { + return InterfaceService::LIST_IFACE[$remoteInstance->getInterface()] ?? (string)$remoteInstance->getInterface(); + } } diff --git a/tests/unit/lib/AuditLog/OperationTest.php b/tests/unit/lib/AuditLog/OperationTest.php new file mode 100644 index 000000000..509e4371d --- /dev/null +++ b/tests/unit/lib/AuditLog/OperationTest.php @@ -0,0 +1,26 @@ + [$operation], Operation::cases()); + } + + #[DataProvider('dataOperations')] + public function testOperationFormat(Operation $operation): void { + $this->assertMatchesRegularExpression('/^circles\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/', $operation->value); + $this->assertNotSame('', $operation->description()); + } +} diff --git a/tests/unit/lib/Command/CirclesScimRemoteInstanceTest.php b/tests/unit/lib/Command/CirclesScimRemoteInstanceTest.php new file mode 100644 index 000000000..357909ce5 --- /dev/null +++ b/tests/unit/lib/Command/CirclesScimRemoteInstanceTest.php @@ -0,0 +1,109 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->auditLogService = $this->createMock(AuditLogService::class); + } + + private function createAddCommand(): CirclesScimAddRemoteInstance { + $remoteInstance = new RemoteInstance(); + $remoteInstance->setInstance('remote.example.com'); + $remoteRequest = $this->createMock(RemoteRequest::class); + $remoteRequest->method('getKnownInstances') + ->willReturn([$remoteInstance]); + + return new CirclesScimAddRemoteInstance($this->appConfig, $remoteRequest, $this->auditLogService); + } + + public function testAdd(): void { + $this->appConfig->method('getAppValueArray') + ->willReturn([]); + $this->appConfig->expects($this->once()) + ->method('setAppValueArray') + ->with(ConfigLexicon::SCIM_REMOTE_INSTANCES, ['remote.example.com']); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::ScimInstanceAdded, 'Remote instance "%s" was allowed to add members to SCIM teams', [ + 'instance' => 'remote.example.com', + ]); + + $this->assertSame(0, $this->createAddCommand()->run(new ArrayInput(['instance' => 'remote.example.com']), new NullOutput())); + } + + public function testAddAlreadyAddedNotLogged(): void { + $this->appConfig->method('getAppValueArray') + ->willReturn(['remote.example.com']); + $this->appConfig->expects($this->never()) + ->method('setAppValueArray'); + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->assertSame(0, $this->createAddCommand()->run(new ArrayInput(['instance' => 'remote.example.com']), new NullOutput())); + } + + public function testAddUnknownInstanceNotLogged(): void { + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->assertSame(1, $this->createAddCommand()->run(new ArrayInput(['instance' => 'unknown.example.com']), new NullOutput())); + } + + public function testRemove(): void { + $scimService = $this->createMock(ScimService::class); + $scimService->expects($this->once()) + ->method('removeRemoteInstanceMembers') + ->with('remote.example.com'); + $this->appConfig->method('getAppValueArray') + ->willReturn(['other.example.com', 'remote.example.com']); + $this->appConfig->expects($this->once()) + ->method('setAppValueArray') + ->with(ConfigLexicon::SCIM_REMOTE_INSTANCES, ['other.example.com']); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::ScimInstanceRemoved, 'Remote instance "%s" is no longer allowed to add members to SCIM teams', [ + 'instance' => 'remote.example.com', + ]); + + $command = new CirclesScimRemoveRemoteInstance($this->appConfig, $scimService, $this->auditLogService); + $this->assertSame(0, $command->run(new ArrayInput(['instance' => 'remote.example.com']), new NullOutput())); + } + + public function testRemoveUnknownInstanceNotLogged(): void { + $this->appConfig->method('getAppValueArray') + ->willReturn(['other.example.com']); + $this->auditLogService->expects($this->never()) + ->method('log'); + + $command = new CirclesScimRemoveRemoteInstance($this->appConfig, $this->createMock(ScimService::class), $this->auditLogService); + $this->assertSame(0, $command->run(new ArrayInput(['instance' => 'remote.example.com']), new NullOutput())); + } +} diff --git a/tests/unit/lib/Controller/SettingsControllerTest.php b/tests/unit/lib/Controller/SettingsControllerTest.php new file mode 100644 index 000000000..4a6f65c28 --- /dev/null +++ b/tests/unit/lib/Controller/SettingsControllerTest.php @@ -0,0 +1,86 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->auditLogService = $this->createMock(AuditLogService::class); + + $this->controller = new SettingsController( + 'circles', + $this->createMock(IRequest::class), + $this->appConfig, + $this->createMock(IGroupManager::class), + $this->auditLogService, + ); + } + + public static function dataSetValue(): array { + return [ + [ConfigLexicon::FEDERATED_TEAMS_ENABLED, 'yes', 'yes'], + [ConfigLexicon::FEDERATED_TEAMS_ENABLED, 'anything', 'no'], + [ConfigLexicon::FEDERATED_TEAMS_FRONTAL, 'https://cloud.example.com/', 'https://cloud.example.com/'], + [ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, '1024', '1024'], + [ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, '{"admin":2048}', '{"admin":2048}'], + ]; + } + + #[DataProvider('dataSetValue')] + public function testSetValue(string $key, string $value, string $loggedValue): void { + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::ConfigUpdated, 'Teams setting "%s" was set to "%s"', [ + 'key' => $key, + 'value' => $loggedValue, + ]); + + $this->assertSame(Http::STATUS_OK, $this->controller->setValue($key, $value)->getStatus()); + } + + public static function dataSetInvalidValue(): array { + return [ + [ConfigLexicon::FEDERATED_TEAMS_FRONTAL, 'not a url'], + [ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, '-1'], + [ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, '{"admin":-1}'], + ['unknown_key', 'value'], + ]; + } + + #[DataProvider('dataSetInvalidValue')] + public function testSetInvalidValueNotLogged(string $key, string $value): void { + foreach (['setAppValueString', 'setAppValueBool', 'setAppValueInt', 'setAppValueArray'] as $method) { + $this->appConfig->expects($this->never()) + ->method($method); + } + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->setValue($key, $value)->getStatus()); + } +} diff --git a/tests/unit/lib/FederatedItems/CircleAuditLogTest.php b/tests/unit/lib/FederatedItems/CircleAuditLogTest.php new file mode 100644 index 000000000..65ad335b8 --- /dev/null +++ b/tests/unit/lib/FederatedItems/CircleAuditLogTest.php @@ -0,0 +1,360 @@ + */ + private array $auditEvents = []; + + protected function setUp(): void { + parent::setUp(); + + $this->auditEvents = []; + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->method('dispatchTyped') + ->willReturnCallback(function (Event $event): void { + if ($event instanceof CriticalActionPerformedEvent) { + $this->auditEvents[] = $event; + } + }); + + $this->configService = $this->createMock(ConfigService::class); + $this->configService->method('isLocalInstance') + ->willReturnCallback(static fn (string $instance): bool => $instance === ''); + $this->configService->method('displayFederatedUser') + ->willReturnCallback(static fn (IFederatedUser $user): string => $user->getUserId() . ($user->getInstance() === '' ? '' : '@' . $user->getInstance())); + + $this->auditLogService = new AuditLogService($eventDispatcher, $this->configService); + } + + private function createEvent(string $class, string $instance = ''): FederatedEvent { + $owner = new Member(); + $owner->setId('ownerMember') + ->setUserId('owner') + ->setUserType(Member::TYPE_USER) + ->setInstance($instance) + ->setLevel(Member::LEVEL_OWNER); + + $initiator = new Member(); + $initiator->setId('initiatorMember') + ->setUserId('alice') + ->setUserType(Member::TYPE_USER) + ->setInstance('') + ->setLevel(Member::LEVEL_ADMIN); + + $circle = new Circle(); + $circle->setSingleId('circle1') + ->setName('Team') + ->setDisplayName('Team') + ->setSource(Member::TYPE_CIRCLE) + ->setConfig(Circle::CFG_OPEN) + ->setOwner($owner) + ->setInitiator($initiator); + + $event = new FederatedEvent($class); + $event->setCircle($circle); + + return $event; + } + + private function assertAuditEvent(Operation $operation, string $message, array $parameters): void { + $this->assertCount(1, $this->auditEvents); + $this->assertSame($operation->value, $this->auditEvents[0]->getOperation()); + $this->assertSame($message, $this->auditEvents[0]->getLogMessage()); + $this->assertSame($parameters, $this->auditEvents[0]->getParameters()); + } + + private function createCircleCreate(CircleRequest $circleRequest): CircleCreate { + $memberRequest = $this->createMock(MemberRequest::class); + $memberRequest->method('getMemberById') + ->willThrowException(new MemberNotFoundException()); + + return new CircleCreate( + $circleRequest, + $memberRequest, + $this->createMock(CircleService::class), + $this->createMock(MembershipService::class), + $this->createMock(MaintenanceService::class), + $this->createMock(EventService::class), + $this->auditLogService, + ); + } + + public function testCircleCreate(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->method('getCircle') + ->willThrowException(new CircleNotFoundException()); + $circleRequest->expects($this->once()) + ->method('save'); + + $this->createCircleCreate($circleRequest)->manage($this->createEvent(CircleCreate::class)); + + $this->assertAuditEvent(Operation::TeamCreated, 'Team %s ("%s") was created with owner "%s" by "%s"', [ + 'circleId' => 'circle1', + 'name' => 'Team', + 'owner' => 'owner', + 'initiator' => 'alice', + ]); + } + + public function testCircleCreateNotLoggedWhenTeamExists(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->method('getCircle') + ->willReturn(new Circle()); + $circleRequest->expects($this->never()) + ->method('save'); + + try { + $this->createCircleCreate($circleRequest)->manage($this->createEvent(CircleCreate::class)); + $this->fail('Expected FederatedEventDSyncException'); + } catch (FederatedEventDSyncException) { + } + + $this->assertSame([], $this->auditEvents); + } + + public function testCircleCreateNotLoggedForGroupMirror(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->method('getCircle') + ->willThrowException(new CircleNotFoundException()); + + $event = $this->createEvent(CircleCreate::class); + $event->getCircle()->setSource(Member::TYPE_GROUP); + $this->createCircleCreate($circleRequest)->manage($event); + + $this->assertSame([], $this->auditEvents); + } + + private function createCircleDestroy(CircleRequest $circleRequest): CircleDestroy { + return new CircleDestroy( + $circleRequest, + $this->createMock(MemberRequest::class), + $this->createMock(EventService::class), + $this->createMock(MembershipService::class), + $this->auditLogService, + ); + } + + public function testCircleDestroy(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->expects($this->once()) + ->method('delete'); + + $this->createCircleDestroy($circleRequest)->manage($this->createEvent(CircleDestroy::class)); + + $this->assertAuditEvent(Operation::TeamDeleted, 'Team %s ("%s") was deleted by "%s"', [ + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ]); + } + + public function testCircleDestroyNotLoggedOnOtherInstances(): void { + $this->createCircleDestroy($this->createMock(CircleRequest::class)) + ->manage($this->createEvent(CircleDestroy::class, 'remote.example.com')); + + $this->assertSame([], $this->auditEvents); + } + + private function createCircleEdit(): CircleEdit { + return new CircleEdit( + $this->createMock(CircleRequest::class), + $this->createMock(MemberRequest::class), + $this->createMock(CircleService::class), + $this->createMock(EventService::class), + $this->auditLogService, + ); + } + + public function testCircleEditRename(): void { + $event = $this->createEvent(CircleEdit::class); + $event->getData()->s('displayName', 'New name'); + + $this->createCircleEdit()->manage($event); + + $this->assertAuditEvent(Operation::TeamRenamed, 'Team %s was renamed from "%s" to "%s" by "%s"', [ + 'circleId' => 'circle1', + 'oldName' => 'Team', + 'name' => 'New name', + 'initiator' => 'alice', + ]); + } + + public function testCircleEditDescriptionNotLogged(): void { + $event = $this->createEvent(CircleEdit::class); + $event->getData()->s('description', 'Some description'); + + $this->createCircleEdit()->manage($event); + + $this->assertSame([], $this->auditEvents); + } + + public function testCircleConfig(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->expects($this->once()) + ->method('updateConfig'); + $item = new CircleConfig( + $circleRequest, + $this->createMock(PermissionService::class), + $this->configService, + $this->auditLogService, + ); + + $event = $this->createEvent(CircleConfig::class); + $event->getData()->sInt('config', Circle::CFG_OPEN | Circle::CFG_VISIBLE); + $item->manage($event); + + $this->assertAuditEvent(Operation::TeamConfigUpdated, 'Configuration of team %s ("%s") was changed from %s to %s by "%s"', [ + 'circleId' => 'circle1', + 'name' => 'Team', + 'oldConfig' => Circle::CFG_OPEN, + 'config' => Circle::CFG_OPEN | Circle::CFG_VISIBLE, + 'initiator' => 'alice', + ]); + } + + public function testCircleConfigUnchangedNotLogged(): void { + $item = new CircleConfig( + $this->createMock(CircleRequest::class), + $this->createMock(PermissionService::class), + $this->configService, + $this->auditLogService, + ); + + $event = $this->createEvent(CircleConfig::class); + $event->getData()->sInt('config', Circle::CFG_OPEN); + $item->manage($event); + + $this->assertSame([], $this->auditEvents); + } + + private function createCircleSetting(): CircleSetting { + return new CircleSetting( + $this->createMock(CircleRequest::class), + $this->createMock(ShareTokenService::class), + $this->configService, + $this->auditLogService, + ); + } + + public function testCircleSettingSet(): void { + $event = $this->createEvent(CircleSetting::class); + $event->setParams(new SimpleDataStore(['setting' => 'password_single', 'value' => 'hashed-secret', 'unset' => false])); + $event->getData()->sArray('settings', ['password_single' => 'hashed-secret']); + + $this->createCircleSetting()->manage($event); + + $this->assertAuditEvent(Operation::TeamSettingUpdated, 'Setting "%s" of team %s ("%s") was set by "%s"', [ + 'setting' => 'password_single', + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ]); + } + + public function testCircleSettingUnset(): void { + $event = $this->createEvent(CircleSetting::class); + $event->setParams(new SimpleDataStore(['setting' => 'enforce_password', 'value' => null, 'unset' => true])); + $event->getData()->sArray('settings', []); + + $this->createCircleSetting()->manage($event); + + $this->assertAuditEvent(Operation::TeamSettingUpdated, 'Setting "%s" of team %s ("%s") was removed by "%s"', [ + 'setting' => 'enforce_password', + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ]); + } + + public function testCircleCreateInvitation(): void { + $repository = $this->createMock(CircleInvitationRepository::class); + $repository->expects($this->once()) + ->method('replace') + ->willReturnArgument(0); + $item = new CircleCreateInvitation($repository, $this->createMock(EventService::class), $this->auditLogService); + + $invitation = new CircleInvitation(); + $invitation->circleId = 'circle1'; + $invitation->invitationCode = 'SECRETCODE'; + $event = $this->createEvent(CircleCreateInvitation::class); + $event->getData()->sObj('circle_invitation', $invitation); + $item->manage($event); + + $this->assertAuditEvent(Operation::InvitationCreated, 'Invitation link of team %s ("%s") was created by "%s"', [ + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ]); + } + + public function testCircleRevokeInvitation(): void { + $repository = $this->createMock(CircleInvitationRepository::class); + $repository->method('deleteBy') + ->with(['circleId' => 'circle1']) + ->willReturn(1); + $item = new CircleRevokeInvitation($repository, $this->createMock(EventService::class), $this->auditLogService); + + $item->manage($this->createEvent(CircleRevokeInvitation::class)); + + $this->assertAuditEvent(Operation::InvitationRevoked, 'Invitation link of team %s ("%s") was revoked by "%s"', [ + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ]); + } + + public function testCircleRevokeInvitationWithoutInvitationNotLogged(): void { + $repository = $this->createMock(CircleInvitationRepository::class); + $repository->method('deleteBy') + ->willReturn(0); + $item = new CircleRevokeInvitation($repository, $this->createMock(EventService::class), $this->auditLogService); + + $item->manage($this->createEvent(CircleRevokeInvitation::class)); + + $this->assertSame([], $this->auditEvents); + } +} diff --git a/tests/unit/lib/FederatedItems/MemberAuditLogTest.php b/tests/unit/lib/FederatedItems/MemberAuditLogTest.php new file mode 100644 index 000000000..fb2d60e99 --- /dev/null +++ b/tests/unit/lib/FederatedItems/MemberAuditLogTest.php @@ -0,0 +1,395 @@ + */ + private array $auditEvents = []; + + protected function setUp(): void { + parent::setUp(); + + $this->auditEvents = []; + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->method('dispatchTyped') + ->willReturnCallback(function (Event $event): void { + if ($event instanceof CriticalActionPerformedEvent) { + $this->auditEvents[] = $event; + } + }); + + $this->configService = $this->createMock(ConfigService::class); + $this->configService->method('isLocalInstance') + ->willReturnCallback(static fn (string $instance): bool => $instance === ''); + $this->configService->method('displayFederatedUser') + ->willReturnCallback(static fn (IFederatedUser $user): string => $user->getUserId() . ($user->getInstance() === '' ? '' : '@' . $user->getInstance())); + + $this->memberService = $this->createMock(MemberService::class); + $this->memberRequest = $this->createMock(MemberRequest::class); + $this->auditLogService = new AuditLogService($eventDispatcher, $this->configService); + } + + private function createMember(string $id, string $userId, int $userType = Member::TYPE_USER, int $level = Member::LEVEL_MEMBER, string $status = Member::STATUS_MEMBER, string $instance = ''): Member { + $member = new Member(); + $member->setId($id) + ->setUserId($userId) + ->setUserType($userType) + ->setInstance($instance) + ->setLevel($level) + ->setStatus($status); + + return $member; + } + + private function createEvent(string $class, ?Member $member = null, string $instance = ''): FederatedEvent { + $circle = new Circle(); + $circle->setSingleId('circle1') + ->setName('Team') + ->setDisplayName('Team') + ->setSource(Member::TYPE_CIRCLE) + ->setOwner($this->createMember('ownerMember', 'owner', level: Member::LEVEL_OWNER, instance: $instance)) + ->setInitiator($this->createMember('initiatorMember', 'alice', level: Member::LEVEL_ADMIN)); + + $event = new FederatedEvent($class); + $event->setCircle($circle); + $event->setMember($member); + + return $event; + } + + /** + * @param list $expected + */ + private function assertAuditEvents(array $expected): void { + $this->assertCount(count($expected), $this->auditEvents); + foreach ($expected as $i => [$operation, $message, $parameters]) { + $this->assertSame($operation->value, $this->auditEvents[$i]->getOperation()); + $this->assertSame($message, $this->auditEvents[$i]->getLogMessage()); + $this->assertSame($parameters, $this->auditEvents[$i]->getParameters()); + } + } + + /** + * @param class-string $class + */ + private function createMemberAdd(string $class): SingleMemberAdd { + return new $class( + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $this->memberRequest, + $this->createMock(FederatedUserService::class), + $this->createMock(RemoteStreamService::class), + $this->createMock(CircleService::class), + $this->memberService, + $this->createMock(MembershipService::class), + $this->createMock(EventService::class), + $this->configService, + $this->createMock(MaintenanceService::class), + $this->auditLogService, + ); + } + + public function testSingleMemberAdd(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + + $this->createMemberAdd(SingleMemberAdd::class) + ->manage($this->createEvent(SingleMemberAdd::class, $this->createMember('member1', 'bob'))); + + $this->assertAuditEvents([[ + Operation::MemberAdded, + 'Member "%s" (%s, member %s) was added to team %s ("%s") as %s by "%s"', + [ + 'userId' => 'bob', + 'memberType' => 'user', + 'memberId' => 'member1', + 'circleId' => 'circle1', + 'name' => 'Team', + 'level' => 'Member', + 'initiator' => 'alice', + ], + ]]); + } + + public function testSingleMemberAddInvitation(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + $member = $this->createMember('member1', 'bob', Member::TYPE_USER, Member::LEVEL_NONE, Member::STATUS_INVITED, 'remote.example.com'); + + $this->createMemberAdd(SingleMemberAdd::class) + ->manage($this->createEvent(SingleMemberAdd::class, $member)); + + $this->assertAuditEvents([[ + Operation::MemberInvited, + 'Member "%s" (%s, member %s) was invited to team %s ("%s") by "%s"', + [ + 'userId' => 'bob@remote.example.com', + 'memberType' => 'user', + 'memberId' => 'member1', + 'circleId' => 'circle1', + 'name' => 'Team', + 'initiator' => 'alice', + ], + ]]); + } + + public function testSingleMemberAddEmailAddressNotLogged(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + + $this->createMemberAdd(SingleMemberAdd::class) + ->manage($this->createEvent(SingleMemberAdd::class, $this->createMember('member1', 'bob@example.com', Member::TYPE_MAIL))); + + $this->assertCount(1, $this->auditEvents); + $this->assertNull($this->auditEvents[0]->getParameters()['userId']); + $this->assertSame('mail', $this->auditEvents[0]->getParameters()['memberType']); + } + + public function testSingleMemberAddFailedNotLogged(): void { + $this->memberService->method('insertOrUpdate')->willReturn(false); + + $this->createMemberAdd(SingleMemberAdd::class) + ->manage($this->createEvent(SingleMemberAdd::class, $this->createMember('member1', 'bob'))); + + $this->assertSame([], $this->auditEvents); + } + + public function testSingleMemberAddToGroupMirrorNotLogged(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + + $event = $this->createEvent(SingleMemberAdd::class, $this->createMember('member1', 'bob')); + $event->getCircle()->setSource(Member::TYPE_GROUP); + $this->createMemberAdd(SingleMemberAdd::class)->manage($event); + + $this->assertSame([], $this->auditEvents); + } + + public function testMassiveMemberAdd(): void { + $this->memberService->method('insertOrUpdate') + ->willReturnCallback(static fn (Member $member): bool => $member->getId() !== 'failed'); + + $event = $this->createEvent(MassiveMemberAdd::class); + $event->setMembers([ + $this->createMember('member1', 'bob'), + $this->createMember('failed', 'carol'), + $this->createMember('member2', 'group1', Member::TYPE_GROUP), + ]); + $this->createMemberAdd(MassiveMemberAdd::class)->manage($event); + + $message = 'Member "%s" (%s, member %s) was added to team %s ("%s") as %s by "%s"'; + $this->assertAuditEvents([ + [Operation::MemberAdded, $message, ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team', 'level' => 'Member', 'initiator' => 'alice']], + [Operation::MemberAdded, $message, ['userId' => 'group1', 'memberType' => 'group', 'memberId' => 'member2', 'circleId' => 'circle1', 'name' => 'Team', 'level' => 'Member', 'initiator' => 'alice']], + ]); + } + + private function createCircleJoin(): CircleJoin { + return new CircleJoin( + $this->createMock(IUserManager::class), + $this->memberRequest, + $this->createMock(FederatedUserService::class), + $this->createMock(CircleService::class), + $this->memberService, + $this->createMock(MembershipService::class), + $this->createMock(EventService::class), + $this->configService, + $this->auditLogService, + ); + } + + public function testCircleJoin(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + + $this->createCircleJoin()->manage($this->createEvent(CircleJoin::class, $this->createMember('member1', 'bob'))); + + $this->assertAuditEvents([[ + Operation::MemberJoined, + 'Member "%s" (%s, member %s) joined team %s ("%s")', + ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team'], + ]]); + } + + public function testCircleJoinRequest(): void { + $this->memberService->method('insertOrUpdate')->willReturn(true); + $member = $this->createMember('member1', 'bob', Member::TYPE_USER, Member::LEVEL_NONE, Member::STATUS_REQUEST); + + $this->createCircleJoin()->manage($this->createEvent(CircleJoin::class, $member)); + + $this->assertAuditEvents([[ + Operation::MemberRequested, + 'Member "%s" (%s, member %s) requested to join team %s ("%s")', + ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team'], + ]]); + } + + public function testCircleJoinFailedNotLogged(): void { + $this->memberService->method('insertOrUpdate')->willReturn(false); + + $this->createCircleJoin()->manage($this->createEvent(CircleJoin::class, $this->createMember('member1', 'bob'))); + + $this->assertSame([], $this->auditEvents); + } + + private function createCircleLeave(?CircleRequest $circleRequest = null): CircleLeave { + return new CircleLeave( + $this->memberRequest, + $circleRequest ?? $this->createMock(CircleRequest::class), + $this->createMock(MembershipService::class), + $this->createMock(EventService::class), + $this->configService, + $this->auditLogService, + ); + } + + public function testCircleLeave(): void { + $this->memberRequest->expects($this->once()) + ->method('delete'); + + $this->createCircleLeave()->manage($this->createEvent(CircleLeave::class, $this->createMember('member1', 'bob'))); + + $this->assertAuditEvents([[ + Operation::MemberLeft, + 'Member "%s" (%s, member %s) left team %s ("%s")', + ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team'], + ]]); + } + + public function testCircleLeaveOfOwner(): void { + $this->memberRequest->method('getMemberById') + ->with('member2') + ->willReturn($this->createMember('member2', 'carol', level: Member::LEVEL_ADMIN)); + + $event = $this->createEvent(CircleLeave::class, $this->createMember('ownerMember', 'owner', level: Member::LEVEL_OWNER)); + $event->getData()->s('newOwnerId', 'member2'); + $this->createCircleLeave()->manage($event); + + $this->assertAuditEvents([ + [ + Operation::MemberLeft, + 'Member "%s" (%s, member %s) left team %s ("%s")', + ['userId' => 'owner', 'memberType' => 'user', 'memberId' => 'ownerMember', 'circleId' => 'circle1', 'name' => 'Team'], + ], + [ + Operation::MemberLevelUpdated, + 'Level of member "%s" (%s, member %s) in team %s ("%s") was changed from %s to %s because the owner left', + ['userId' => 'carol', 'memberType' => 'user', 'memberId' => 'member2', 'circleId' => 'circle1', 'name' => 'Team', 'oldLevel' => 'Admin', 'level' => 'Owner'], + ], + ]); + } + + public function testCircleLeaveOfLastMember(): void { + $circleRequest = $this->createMock(CircleRequest::class); + $circleRequest->expects($this->once()) + ->method('delete'); + + $event = $this->createEvent(CircleLeave::class, $this->createMember('ownerMember', 'owner', level: Member::LEVEL_OWNER)); + $event->getData()->sBool('destroyCircle', true); + $this->createCircleLeave($circleRequest)->manage($event); + + $this->assertAuditEvents([ + [ + Operation::MemberLeft, + 'Member "%s" (%s, member %s) left team %s ("%s")', + ['userId' => 'owner', 'memberType' => 'user', 'memberId' => 'ownerMember', 'circleId' => 'circle1', 'name' => 'Team'], + ], + [ + Operation::TeamDeleted, + 'Team %s ("%s") was deleted because its last member "%s" left', + ['circleId' => 'circle1', 'name' => 'Team', 'userId' => 'owner'], + ], + ]); + } + + public function testCircleLeaveNotLoggedOnOtherInstances(): void { + $this->createCircleLeave()->manage($this->createEvent(CircleLeave::class, $this->createMember('member1', 'bob'), 'remote.example.com')); + + $this->assertSame([], $this->auditEvents); + } + + public function testMemberRemove(): void { + $this->memberRequest->expects($this->once()) + ->method('delete'); + $item = new MemberRemove($this->memberRequest, $this->createMock(MembershipService::class), $this->createMock(EventService::class), $this->auditLogService); + + $item->manage($this->createEvent(MemberRemove::class, $this->createMember('member1', 'bob'))); + + $this->assertAuditEvents([[ + Operation::MemberRemoved, + 'Member "%s" (%s, member %s) was removed from team %s ("%s") by "%s"', + ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team', 'initiator' => 'alice'], + ]]); + } + + private function createMemberLevel(): MemberLevel { + return new MemberLevel($this->memberRequest, $this->createMock(MembershipService::class), $this->createMock(EventService::class), $this->configService, $this->auditLogService); + } + + public function testMemberLevel(): void { + $this->memberRequest->expects($this->once()) + ->method('updateLevel'); + + $event = $this->createEvent(MemberLevel::class, $this->createMember('member1', 'bob')); + $event->getData()->sInt('level', Member::LEVEL_MODERATOR); + $this->createMemberLevel()->manage($event); + + $this->assertAuditEvents([[ + Operation::MemberLevelUpdated, + 'Level of member "%s" (%s, member %s) in team %s ("%s") was changed from %s to %s by "%s"', + ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team', 'oldLevel' => 'Member', 'level' => 'Moderator', 'initiator' => 'alice'], + ]]); + } + + public function testMemberLevelOwnershipTransfer(): void { + $this->memberRequest->expects($this->once()) + ->method('promoteToOwner') + ->willReturn($this->createMember('ownerMember', 'owner', level: Member::LEVEL_ADMIN)); + + $event = $this->createEvent(MemberLevel::class, $this->createMember('member1', 'bob', level: Member::LEVEL_ADMIN)); + $event->getData()->sInt('level', Member::LEVEL_OWNER); + $this->createMemberLevel()->manage($event); + + $message = 'Level of member "%s" (%s, member %s) in team %s ("%s") was changed from %s to %s by "%s"'; + $this->assertAuditEvents([ + [Operation::MemberLevelUpdated, $message, ['userId' => 'bob', 'memberType' => 'user', 'memberId' => 'member1', 'circleId' => 'circle1', 'name' => 'Team', 'oldLevel' => 'Admin', 'level' => 'Owner', 'initiator' => 'alice']], + [Operation::MemberLevelUpdated, $message, ['userId' => 'owner', 'memberType' => 'user', 'memberId' => 'ownerMember', 'circleId' => 'circle1', 'name' => 'Team', 'oldLevel' => 'Owner', 'level' => 'Admin', 'initiator' => 'alice']], + ]); + } +} diff --git a/tests/unit/lib/Service/AuditLogServiceTest.php b/tests/unit/lib/Service/AuditLogServiceTest.php new file mode 100644 index 000000000..7360a96a5 --- /dev/null +++ b/tests/unit/lib/Service/AuditLogServiceTest.php @@ -0,0 +1,143 @@ +eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->configService = $this->createMock(ConfigService::class); + $this->configService->method('isLocalInstance') + ->willReturnCallback(static fn (string $instance): bool => $instance === '' || $instance === 'cloud.example.com'); + $this->configService->method('displayFederatedUser') + ->willReturnCallback(static fn (IFederatedUser $user): string => $user->getUserId() . '@' . $user->getInstance()); + + $this->service = new AuditLogService($this->eventDispatcher, $this->configService); + } + + private function createCircle(string $instance, int $source = Member::TYPE_CIRCLE): Circle { + $owner = new Member(); + $owner->setUserId('owner') + ->setUserType(Member::TYPE_USER) + ->setInstance($instance); + + $circle = new Circle(); + $circle->setSingleId('circle1') + ->setSource($source) + ->setOwner($owner); + + return $circle; + } + + public function testLog(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Team %s was deleted', $event->getLogMessage()); + $this->assertSame(['circleId' => 'circle1'], $event->getParameters()); + $this->assertFalse($event->getObfuscateParameters()); + $this->assertSame(Operation::TeamDeleted->value, $event->getOperation()); + return true; + })); + + $this->service->log(Operation::TeamDeleted, 'Team %s was deleted', ['circleId' => 'circle1']); + } + + public function testLogTeamChangeOnOwningInstance(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(CriticalActionPerformedEvent::class)); + + $this->service->logTeamChange($this->createCircle('cloud.example.com'), Operation::TeamDeleted, 'Team %s was deleted', ['circleId' => 'circle1']); + } + + public function testLogTeamChangeSkipsTeamOwnedByAnotherInstance(): void { + $this->eventDispatcher->expects($this->never()) + ->method('dispatchTyped'); + + $this->service->logTeamChange($this->createCircle('remote.example.com'), Operation::TeamDeleted, 'Team %s was deleted', ['circleId' => 'circle1']); + } + + public function testLogTeamChangeSkipsGroupMirror(): void { + $this->eventDispatcher->expects($this->never()) + ->method('dispatchTyped'); + + $this->service->logTeamChange($this->createCircle('', Member::TYPE_GROUP), Operation::TeamDeleted, 'Team %s was deleted', ['circleId' => 'circle1']); + } + + public function testLogTeamChangeWithoutOwner(): void { + $circle = new Circle(); + $circle->setSingleId('circle1'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped'); + + $this->service->logTeamChange($circle, Operation::TeamDeleted, 'Team %s was deleted', ['circleId' => 'circle1']); + } + + public static function dataGetUserId(): array { + return [ + [Member::TYPE_USER, 'alice@cloud.example.com'], + [Member::TYPE_GROUP, 'alice@cloud.example.com'], + [Member::TYPE_CIRCLE, 'alice@cloud.example.com'], + [Member::TYPE_APP, 'alice@cloud.example.com'], + [Member::TYPE_MAIL, null], + [Member::TYPE_CONTACT, null], + ]; + } + + #[DataProvider('dataGetUserId')] + public function testGetUserId(int $userType, ?string $expected): void { + $member = new Member(); + $member->setUserId('alice') + ->setUserType($userType) + ->setInstance('cloud.example.com'); + + $this->assertSame($expected, $this->service->getUserId($member)); + } + + public function testGetInitiator(): void { + $initiator = new Member(); + $initiator->setUserId('alice') + ->setUserType(Member::TYPE_USER) + ->setInstance('cloud.example.com'); + $circle = new Circle(); + $circle->setInitiator($initiator); + + $this->assertSame('alice@cloud.example.com', $this->service->getInitiator($circle)); + $this->assertNull($this->service->getInitiator(new Circle())); + } + + public function testGetTeamName(): void { + $circle = new Circle(); + $circle->setName('name'); + $this->assertSame('name', $this->service->getTeamName($circle)); + + $circle->setDisplayName('Display name'); + $this->assertSame('Display name', $this->service->getTeamName($circle)); + } +} diff --git a/tests/unit/lib/Service/RemoteStreamServiceTest.php b/tests/unit/lib/Service/RemoteStreamServiceTest.php new file mode 100644 index 000000000..408fea27a --- /dev/null +++ b/tests/unit/lib/Service/RemoteStreamServiceTest.php @@ -0,0 +1,142 @@ +remoteRequest = $this->createMock(RemoteRequest::class); + $this->configService = $this->createMock(ConfigService::class); + $this->auditLogService = $this->createMock(AuditLogService::class); + + $this->service = new RemoteStreamService( + $this->createMock(IConfig::class), + $this->createMock(IURLGenerator::class), + $this->remoteRequest, + $this->createMock(InterfaceService::class), + $this->configService, + $this->auditLogService, + ); + } + + private function createRemoteInstance(): RemoteInstance { + $remoteInstance = new RemoteInstance(); + $remoteInstance->setInstance('remote.example.com') + ->setType(RemoteInstance::TYPE_TRUSTED) + ->setInterface(InterfaceService::IFACE_FRONTAL); + + return $remoteInstance; + } + + public function testSaveRemoteInstance(): void { + $remoteInstance = $this->createRemoteInstance(); + $this->remoteRequest->expects($this->once()) + ->method('save') + ->with($remoteInstance); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::RemoteInstanceAdded, 'Remote instance "%s" was added as %s using the %s interface', [ + 'instance' => 'remote.example.com', + 'type' => 'Trusted', + 'interface' => 'frontal', + ]); + + $this->service->saveRemoteInstance($remoteInstance); + } + + public function testAddLocalInstanceNotLogged(): void { + $this->configService->method('isLocalInstance') + ->willReturn(true); + $this->remoteRequest->expects($this->never()) + ->method('save'); + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->expectException(RemoteAlreadyExistsException::class); + $this->service->addRemoteInstance('cloud.example.com'); + } + + public function testRemoveRemoteInstance(): void { + $this->remoteRequest->expects($this->once()) + ->method('deleteInstance') + ->with('remote.example.com') + ->willReturn(1); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::RemoteInstanceRemoved, 'Remote instance "%s" was removed', [ + 'instance' => 'remote.example.com', + ]); + + $this->service->removeRemoteInstance('remote.example.com'); + } + + public function testRemoveUnknownRemoteInstanceNotLogged(): void { + $this->remoteRequest->method('deleteInstance') + ->willReturn(0); + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->service->removeRemoteInstance('unknown.example.com'); + } + + public function testUpdateIdentity(): void { + $this->remoteRequest->expects($this->once()) + ->method('update'); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::RemoteInstanceUpdated, 'Identity of remote instance "%s" was changed, it is known as %s', [ + 'instance' => 'remote.example.com', + 'type' => 'Trusted', + ]); + + $this->service->update($this->createRemoteInstance()); + } + + public function testUpdateType(): void { + $this->remoteRequest->expects($this->once()) + ->method('updateType'); + $this->auditLogService->expects($this->once()) + ->method('log') + ->with(Operation::RemoteInstanceUpdated, 'Type of remote instance "%s" was changed to %s', [ + 'instance' => 'remote.example.com', + 'type' => 'Trusted', + ]); + + $this->service->update($this->createRemoteInstance(), RemoteStreamService::UPDATE_TYPE); + } + + public function testUpdateItemNotLogged(): void { + $this->remoteRequest->expects($this->once()) + ->method('updateItem'); + $this->auditLogService->expects($this->never()) + ->method('log'); + + $this->service->update($this->createRemoteInstance(), RemoteStreamService::UPDATE_ITEM); + } +}