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
28 changed files with 2819 additions and 1859 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

4394
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,13 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^3.7.0", "@inertiajs/vue3": "^2.3.23",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@types/node": "^26.2.0", "@types/node": "^22.19.19",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
@@ -40,14 +40,14 @@
"axios": "^1.16.0", "axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9", "happy-dom": "^20.8.9",
"laravel-vite-plugin": "^3.2.0", "laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.18.3", "openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"postcss-import": "^17.0.0", "postcss-import": "^15.1.0",
"postcss-nesting": "^14.0.1", "postcss-nesting": "^12.1.5",
"tailwindcss": "^4.3.3", "tailwindcss": "^3.4.19",
"typescript": "^7.0.2", "typescript": "^5.9.3",
"vite": "^8.2.2", "vite": "^7.3.3",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4", "vitest": "^4.1.4",
"vue": "^3.5.34", "vue": "^3.5.34",
@@ -55,15 +55,15 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^2.0.1", "@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0", "@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0", "@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^6.1.41", "@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^9.1.2", "@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.24", "@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
@@ -77,10 +77,10 @@
"echarts": "^6.0.0", "echarts": "^6.0.0",
"focus-trap": "^8.2.0", "focus-trap": "^8.2.0",
"parse-duration": "^2.1.6", "parse-duration": "^2.1.6",
"pinia": "^4.0.3", "pinia": "^3.0.4",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.9.7", "reka-ui": "^2.9.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1", "vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1", "vue-echarts": "^8.0.1",

View File

@@ -29,7 +29,7 @@
"author": "solidtime", "author": "solidtime",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^5.0.3" "vite-plugin-dts": "^4.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",

View File

@@ -50,7 +50,7 @@
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"vite-plugin-dts": "^5.0.3", "vite-plugin-dts": "^4.5.4",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"peerDependencies": { "peerDependencies": {

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);
}
}