Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions features/api_roundcube.feature
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Feature: Roundcube API
And the following Domain exists:
| name |
| example.org |
| example.com |
And the following User exists:
| email | password | roles |
| user@example.org | password | ROLE_USER |
Expand Down Expand Up @@ -59,3 +60,17 @@ Feature: Roundcube API
"""
[]
"""

@aliases
Scenario: Get cross-domain aliases for user
Given the following Alias exists:
| user_id | source | destination |
| 2 | crossdomain@example.com | user2@example.org |
And I have a valid API token "roundcube-test-123"
When I send a POST request to "/api/roundcube/aliases" with form data:
| email | user2@example.org |
| password | password |
Then the response status code should equal 200
And the JSON response should contain "alias@example.org"
And the JSON response should contain "alias2@example.org"
And the JSON response should contain "crossdomain@example.com"
11 changes: 11 additions & 0 deletions features/openpgp.feature
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Feature: OpenPGP
And the following Domain exists:
| name |
| example.org |
| example.com |
And the following User exists:
| email | password |
| alice@example.org | asdasd |
Expand Down Expand Up @@ -408,3 +409,13 @@ Feature: OpenPGP

Then I should be on "/account/openpgp"
And I should see text matching "You are not authorized to manage this identity."

@openpgp-cross-domain-identity
Scenario: Cross-domain alias appears as identity on OpenPGP page
Given the following Alias exists:
| user_id | source | destination | deleted | random |
| 1 | crossdomain@example.com | alice@example.org | 0 | 0 |
When I am authenticated as "alice@example.org"
And I am on "/account/openpgp"
Then the response status code should be 200
And I should see "crossdomain@example.com"
19 changes: 19 additions & 0 deletions features/user.feature
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Feature: User
And the following Domain exists:
| name |
| example.org |
| example.com |
And the following User exists:
| email | password | roles |
| admin@example.org | asdasd | ROLE_ADMIN |
Expand Down Expand Up @@ -131,6 +132,14 @@ Feature: User

Then I should be on "/"

@delete-user-cross-domain-aliases
Scenario: Deleting a user also deletes their cross-domain aliases
Given the following Alias exists:
| user_id | source | destination | deleted | random |
| 2 | crossdomain@example.com | user@example.org | 0 | 0 |
When the user "user@example.org" is deleted
Then the alias "crossdomain@example.com" should be deleted

@create-voucher
Scenario: Create voucher as Admin
When I am authenticated as "admin@example.org"
Expand Down Expand Up @@ -292,6 +301,16 @@ Feature: User
And I am on "/account/alias"
Then the response status code should be 403

@alias-cross-domain
Scenario: User can see cross-domain aliases on alias page
Given the following Alias exists:
| user_id | source | destination | deleted | random |
| 2 | crossdomain@example.com | user@example.org | 0 | 0 |
When I am authenticated as "user@example.org"
And I am on "/account/alias"
Then the response status code should be 200
And I should see "crossdomain@example.com"

@voucher-access
Scenario: Unauthenticated user is redirected to login from voucher page
When I am on "/account/voucher"
Expand Down
4 changes: 2 additions & 2 deletions src/Controller/Account/AliasController.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ public function show(): Response
);

$aliasRepository = $this->manager->getRepository(Alias::class);
$aliasesRandom = $aliasRepository->findByUser($user, true, true);
$aliasesCustom = $aliasRepository->findByUser($user, false, true);
$aliasesRandom = $aliasRepository->findByUserAcrossDomains($user, random: true);
$aliasesCustom = $aliasRepository->findByUserAcrossDomains($user, random: false);

return $this->render(
'Alias/show.html.twig',
Expand Down
4 changes: 2 additions & 2 deletions src/Controller/Account/OpenPGPController.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ private function buildIdentities(User $user): array
];

// Non-random alias sources from all domains
$aliases = $this->aliasRepository->findByUser($user, false, true);
$aliases = $this->aliasRepository->findByUserAcrossDomains($user, random: false);
foreach ($aliases as $alias) {
$source = $alias->getSource();
if (null !== $source) {
Expand All @@ -175,7 +175,7 @@ private function canManageIdentity(User $user, string $email): bool
return true;
}

$aliases = $this->aliasRepository->findByUser($user, false);
$aliases = $this->aliasRepository->findByUserAcrossDomains($user, false);

return array_any($aliases, static fn ($alias) => $alias->getSource() === $email);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Controller/Api/RoundcubeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ public function postUserAliases(
throw new AuthenticationException('Bad credentials', 401);
}

$aliases = $this->manager->getRepository(Alias::class)->findByUser($user);
// Uses findByUserAcrossDomains to include cross-domain aliases. The domain
// filter is typically not active for API-authenticated requests (see the
// method's doc comment), but this ensures correct behavior regardless.
$aliases = $this->manager->getRepository(Alias::class)->findByUserAcrossDomains($user);
$aliasSources = array_map(static fn ($alias) => $alias->getSource(), $aliases);

return $this->json($aliasSources);
Expand Down
4 changes: 2 additions & 2 deletions src/Handler/DeleteHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function deleteAlias(Alias $alias, ?User $user = null): void
public function deleteUser(User $user): void
{
// Delete aliases of user
$aliases = $this->manager->getRepository(Alias::class)->findByUser($user);
$aliases = $this->manager->getRepository(Alias::class)->findByUserAcrossDomains($user);
foreach ($aliases as $alias) {
$alias->setDeleted(true);
$alias->clearSensitiveData();
Expand Down Expand Up @@ -86,7 +86,7 @@ public function deleteUser(User $user): void
$this->manager->flush();

// Get custom aliases from all domains
$customAliases = $this->manager->getRepository(Alias::class)->findByUser($user, false, true);
$customAliases = $this->manager->getRepository(Alias::class)->findByUserAcrossDomains($user, random: false);
foreach ($customAliases as $customAlias) {
$this->eventDispatcher->dispatch(new AliasDeletedEvent($customAlias), AliasDeletedEvent::CUSTOM);
}
Expand Down
39 changes: 32 additions & 7 deletions src/Repository/AliasRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,20 +136,45 @@ private function applyFilters(QueryBuilder $qb, string $search, ?Domain $domain,
}

/**
* @return array|Alias[]
* @return Alias[]
*/
public function findByUser(User $user, ?bool $random = null, ?bool $disableDomainFilter = false): array
public function findByUser(User $user, ?bool $random = null): array
{
$criteria = ['user' => $user, 'deleted' => false];

if (null !== $random) {
$criteria['random'] = $random;
}

return $this->findBy($criteria);
}

/**
* Find active aliases owned by the user across all domains.
*
* Temporarily disables the domain filter to include cross-domain aliases
* (e.g. aliases where the source domain differs from the user's domain).
* If the domain filter is not active (e.g. for API-authenticated requests
* where BeforeRequestListener does not enable it), this behaves identically
* to findByUser().
*
* @return Alias[]
*/
public function findByUserAcrossDomains(User $user, ?bool $random = null): array
{
$filters = $this->getEntityManager()->getFilters();
$wasEnabled = $filters->isEnabled('domain_filter');

if ($filters->isEnabled('domain_filter') && $disableDomainFilter == true) {
if ($wasEnabled) {
$filters->disable('domain_filter');
}

if (isset($random)) {
return $this->findBy(['user' => $user, 'random' => $random, 'deleted' => false]);
try {
return $this->findByUser($user, $random);
} finally {
if ($wasEnabled) {
$filters->enable('domain_filter');
}
}

return $this->findBy(['user' => $user, 'deleted' => false]);
}
}
34 changes: 34 additions & 0 deletions tests/Behat/ApiContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,40 @@ public function theJsonResponseShouldContainItems(int $count): void
}
}

/**
* @Then the JSON response should contain :value
*/
public function theJsonResponseShouldContain(string $value): void
{
$content = $this->getSession()->getPage()->getContent();
$data = json_decode($content, true);

if (JSON_ERROR_NONE !== json_last_error()) {
throw new RuntimeException(sprintf('Response is not valid JSON: %s', json_last_error_msg()));
}

if (!is_array($data) || !in_array($value, $data, true)) {
throw new RuntimeException(sprintf('JSON response does not contain "%s". Response: %s', $value, $content));
}
}

/**
* @Then the JSON response should not contain :value
*/
public function theJsonResponseShouldNotContain(string $value): void
{
$content = $this->getSession()->getPage()->getContent();
$data = json_decode($content, true);

if (JSON_ERROR_NONE !== json_last_error()) {
throw new RuntimeException(sprintf('Response is not valid JSON: %s', json_last_error_msg()));
}

if (is_array($data) && in_array($value, $data, true)) {
throw new RuntimeException(sprintf('JSON response should not contain "%s" but it does. Response: %s', $value, $content));
}
}

/**
* @throws UnsupportedDriverActionException
*/
Expand Down
33 changes: 33 additions & 0 deletions tests/Behat/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,39 @@ public function theUserShouldExist(string $email): void
}
}

/**
* @When /^the user "([^"]*)" is deleted$/
*/
public function theUserIsDeleted(string $email): void
{
$user = $this->getUserRepository()->findByEmail($email);

if (null === $user) {
throw new RuntimeException(sprintf('User "%s" does not exist', $email));
}

$deleteHandler = $this->getContainer()->get('App\Handler\DeleteHandler');
$deleteHandler->deleteUser($user);
}

/**
* @Then /^the alias "([^"]*)" should be deleted$/
*/
public function theAliasShouldBeDeleted(string $source): void
{
$this->manager->clear();

$alias = $this->manager->getRepository(Alias::class)->findOneBySource($source, includeDeleted: true);

if (null === $alias) {
throw new RuntimeException(sprintf('Alias "%s" does not exist', $source));
}

if (!$alias->isDeleted()) {
throw new RuntimeException(sprintf('Alias "%s" is not deleted', $source));
}
}

/**
* @Then /^the user "([^"]*)" should have passwordChangeRequired$/
*/
Expand Down
12 changes: 11 additions & 1 deletion tests/Handler/DeleteHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Tests\Handler;

use App\Entity\Alias;
use App\Entity\Domain;
use App\Entity\User;
use App\Entity\UserNotification;
use App\Entity\Voucher;
Expand Down Expand Up @@ -34,6 +35,7 @@ protected function createHandler(array $aliases = [], array $vouchers = [], arra

$aliasRepository = $this->createStub(AliasRepository::class);
$aliasRepository->method('findByUser')->willReturn($aliases);
$aliasRepository->method('findByUserAcrossDomains')->willReturn($aliases);

$voucherRepository = $this->createStub(VoucherRepository::class);
$voucherRepository->method('findByUser')->willReturn($vouchers);
Expand Down Expand Up @@ -169,12 +171,20 @@ public function testDeleteUserDeletesAliases(): void
$alias2->setUser($user);
$alias2->setSource('alias2@example.org');

$handler = $this->createHandler([$alias1, $alias2]);
$alias3 = new Alias();
$alias3->setUser($user);
$alias3->setSource('crossdomain@example.com');
$crossDomain = new Domain();
$crossDomain->setName('example.com');
$alias3->setDomain($crossDomain);

$handler = $this->createHandler([$alias1, $alias2, $alias3]);

$handler->deleteUser($user);

self::assertTrue($alias1->isDeleted());
self::assertTrue($alias2->isDeleted());
self::assertTrue($alias3->isDeleted());
}

public function testDeleteUserRemovesNotifications(): void
Expand Down
Loading