Compare commits

...

2 Commits

Author SHA1 Message Date
Constantin Graf
0d1d9a4ee7 Suppress expected OAuth access denial reports 2026-08-31 11:39:30 +02:00
Constantin Graf
ba778bdddf Suppress reporting for expected API exceptions 2026-08-31 11:14:23 +02:00
24 changed files with 251 additions and 1 deletions

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/ */
public function report(): bool public function report(): bool
{ {
// TODO: temporary activated
return false; return false;
} }
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{ {
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members'; public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException class CanNotRemoveOwnerFromOrganization extends ApiException
{ {
public const string KEY = 'can_not_remove_owner_from_organization'; public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_of_placeholder_is_not_allowed'; public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_to_placeholder_is_not_allowed'; public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use'; public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/** /**
* Get the translated message for the exception. * Get the translated message for the exception.
*/ */

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{ {
public const string KEY = 'feature_is_not_available_in_free_plan'; public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException class InactiveUserCanNotBeUsedApiException extends ApiException
{ {
public const string KEY = 'inactive_user_can_not_be_used'; public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{ {
public const string KEY = 'invitation_for_the_email_already_exists'; public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException class OnlyOwnerCanChangeOwnership extends ApiException
{ {
public const string KEY = 'only_owner_can_change_ownership'; public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{ {
public const string KEY = 'only_placeholders_can_be_merged_into_another_member'; public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{ {
public const string KEY = 'organization_has_no_subscription_but_multiple_members'; public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException class OrganizationNeedsAtLeastOneOwner extends ApiException
{ {
public const string KEY = 'organization_needs_at_least_one_owner'; public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException class OverlappingTimeEntryApiException extends ApiException
{ {
public const string KEY = 'overlapping_time_entry'; public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{ {
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception'; public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException class TimeEntryCanNotBeRestartedApiException extends ApiException
{ {
public const string KEY = 'time_entry_can_not_be_restarted'; public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException class TimeEntryStillRunningApiException extends ApiException
{ {
public const string KEY = 'time_entry_still_running'; public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_organization'; public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException class UserIsAlreadyMemberOfProjectApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_project'; public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException class UserNotPlaceholderApiException extends ApiException
{ {
public const string KEY = 'user_not_placeholder'; public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{ {
public const string KEY = 'user_resend_email_verification_no_pending_email'; public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -34,6 +35,10 @@ class Handler extends ExceptionHandler
// //
}); });
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// A request on an untrusted host (see App\Http\Middleware\TrustHosts) // A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that // otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the // says how to fix it instead. The framework has already converted the

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Exceptions\Api;
use App\Exceptions\Api\ApiException;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
use App\Service\Export\ExportException;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class ApiExceptionTest extends TestCase
{
/**
* @return iterable<string, array{ApiException}>
*/
public static function expectedApiExceptionProvider(): iterable
{
yield CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::class => [new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers];
yield CanNotRemoveOwnerFromOrganization::class => [new CanNotRemoveOwnerFromOrganization];
yield ChangingRoleOfPlaceholderIsNotAllowed::class => [new ChangingRoleOfPlaceholderIsNotAllowed];
yield ChangingRoleToPlaceholderIsNotAllowed::class => [new ChangingRoleToPlaceholderIsNotAllowed];
yield EntityStillInUseApiException::class => [new EntityStillInUseApiException('member', 'time_entry')];
yield FeatureIsNotAvailableInFreePlanApiException::class => [new FeatureIsNotAvailableInFreePlanApiException];
yield InactiveUserCanNotBeUsedApiException::class => [new InactiveUserCanNotBeUsedApiException];
yield InvitationForTheEmailAlreadyExistsApiException::class => [new InvitationForTheEmailAlreadyExistsApiException];
yield OnlyOwnerCanChangeOwnership::class => [new OnlyOwnerCanChangeOwnership];
yield OnlyPlaceholdersCanBeMergedIntoAnotherMember::class => [new OnlyPlaceholdersCanBeMergedIntoAnotherMember];
yield OrganizationHasNoSubscriptionButMultipleMembersException::class => [new OrganizationHasNoSubscriptionButMultipleMembersException];
yield OrganizationNeedsAtLeastOneOwner::class => [new OrganizationNeedsAtLeastOneOwner];
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
yield UserNotPlaceholderApiException::class => [new UserNotPlaceholderApiException];
yield UserResendEmailVerificationNoPendingEmailApiException::class => [new UserResendEmailVerificationNoPendingEmailApiException];
}
#[DataProvider('expectedApiExceptionProvider')]
public function test_expected_api_exceptions_are_not_reported(ApiException $exception): void
{
// Arrange: exception supplied by the data provider
// Act
$reportingHandled = $exception->report();
// Assert
$this->assertTrue($reportingHandled);
}
/**
* @return iterable<string, array{ApiException}>
*/
public static function operationalApiExceptionProvider(): iterable
{
yield PdfRendererIsNotConfiguredException::class => [new PdfRendererIsNotConfiguredException];
yield PersonalAccessClientIsNotConfiguredException::class => [new PersonalAccessClientIsNotConfiguredException];
yield ExportException::class => [new ExportException];
}
#[DataProvider('operationalApiExceptionProvider')]
public function test_operational_api_exceptions_are_reported(ApiException $exception): void
{
// Arrange: exception supplied by the data provider
// Act
$reportingHandled = $exception->report();
// Assert
$this->assertFalse($reportingHandled);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Exceptions;
use App\Exceptions\Handler;
use League\OAuth2\Server\Exception\OAuthServerException;
use RuntimeException;
use Tests\TestCase;
class HandlerTest extends TestCase
{
public function test_oauth_access_denied_exceptions_are_not_reported(): void
{
// Arrange
$exception = OAuthServerException::accessDenied(
'Access token could not be verified',
previous: new RuntimeException('The token is expired')
);
// Act
$shouldReport = app(Handler::class)->shouldReport($exception);
// Assert
$this->assertFalse($shouldReport);
}
public function test_operational_oauth_exceptions_are_reported(): void
{
// Arrange
$exception = OAuthServerException::serverError('Signing key could not be read');
// Act
$shouldReport = app(Handler::class)->shouldReport($exception);
// Assert
$this->assertTrue($shouldReport);
}
}