mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-17 18:23:19 +01:00
Compare commits
5 Commits
fb7d945c50
...
feature/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d1d9a4ee7 | ||
|
|
ba778bdddf | ||
|
|
28aba7b27a | ||
|
|
453d5ed066 | ||
|
|
ed3ba0135b |
@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
|
|||||||
*/
|
*/
|
||||||
public function report(): bool
|
public function report(): bool
|
||||||
{
|
{
|
||||||
// TODO: temporary activated
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class ExportController extends Controller
|
|||||||
|
|
||||||
$filepath = $exportService->export($organization);
|
$filepath = $exportService->export($organization);
|
||||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
||||||
|
]);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -336,7 +336,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +549,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Service\BillingContract;
|
|||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\IpLookup\NoIpLookupService;
|
use App\Service\IpLookup\NoIpLookupService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
|
use DateTimeInterface;
|
||||||
use Dedoc\Scramble\Scramble;
|
use Dedoc\Scramble\Scramble;
|
||||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||||
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||||
$this->app->bind(BillingContract::class);
|
$this->app->bind(BillingContract::class);
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||||
|
// so mirror it through the signed query parameters of the storage route.
|
||||||
|
$privateDisk = config('filesystems.private');
|
||||||
|
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
|
||||||
|
$disk = Storage::disk($privateDisk);
|
||||||
|
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
|
||||||
|
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
|
||||||
|
});
|
||||||
|
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
|
||||||
|
$parameters = array_filter([
|
||||||
|
'path' => $path,
|
||||||
|
'disposition' => isset($options['ResponseContentDisposition'])
|
||||||
|
? Str::before($options['ResponseContentDisposition'], ';')
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
Route::model('member', Member::class);
|
Route::model('member', Member::class);
|
||||||
Route::model('invitation', OrganizationInvitation::class);
|
Route::model('invitation', OrganizationInvitation::class);
|
||||||
|
|||||||
@@ -1297,7 +1297,7 @@ test('test that searching projects by name works', async ({ page, ctx }) => {
|
|||||||
await createProjectViaApi(ctx, { name: otherProjectName });
|
await createProjectViaApi(ctx, { name: otherProjectName });
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearTableState(page, 'project-table-state');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.getByText(otherProjectName)).toBeVisible();
|
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
|
|||||||
|
|
||||||
function downloadCurrentExport() {
|
function downloadCurrentExport() {
|
||||||
if (props.exportUrl) {
|
if (props.exportUrl) {
|
||||||
window.open(props.exportUrl, '_blank')?.focus();
|
window.open(props.exportUrl, '_self');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Testing\TestResponse;
|
use Illuminate\Testing\TestResponse;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
@@ -16,4 +19,26 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
|
|||||||
}
|
}
|
||||||
$response->assertStatus($statusCode);
|
$response->assertStatus($statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the temporary URL builder of the private disk to capture the options
|
||||||
|
* passed to temporaryUrl. Returns a closure that yields the captured options.
|
||||||
|
*
|
||||||
|
* @return Closure(): (array<string, mixed>|null)
|
||||||
|
*/
|
||||||
|
protected function captureTemporaryUrlOptions(): Closure
|
||||||
|
{
|
||||||
|
$captured = null;
|
||||||
|
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
|
||||||
|
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
|
||||||
|
$captured = $options;
|
||||||
|
|
||||||
|
return 'https://storage.fake/'.$path;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return function () use (&$captured): ?array {
|
||||||
|
return $captured;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,4 +90,35 @@ class ExportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_export_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission([
|
||||||
|
'export',
|
||||||
|
]);
|
||||||
|
$filepath = 'exports/export_test.zip';
|
||||||
|
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
|
||||||
|
$mock->shouldReceive('export')
|
||||||
|
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||||
|
return $organization->is($user->organization);
|
||||||
|
})
|
||||||
|
->andReturn($filepath)
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.export.export', [
|
||||||
|
'organization' => $user->organization->getKey(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertSame('attachment; filename="export_test.zip"', $options['ResponseContentDisposition'] ?? null);
|
||||||
|
$this->assertSame('https://storage.fake/'.$filepath, $response->json('download_url'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -947,6 +947,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^attachment; filename="time-entries-export-.+\.csv"$/',
|
||||||
|
$options['ResponseContentDisposition'] ?? ''
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1321,6 +1350,38 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^attachment; filename="time-entries-report-.+\.csv"$/',
|
||||||
|
$options['ResponseContentDisposition'] ?? ''
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Endpoint\Web;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the attachment downloads of the private disk via Laravel's storage.{disk} route,
|
||||||
|
* configured with serveUsing/buildTemporaryUrlsUsing in AppServiceProvider::boot.
|
||||||
|
* These tests use the real private disk, because Storage::fake would replace the disk
|
||||||
|
* instance and thereby remove that configuration.
|
||||||
|
*/
|
||||||
|
class FileDownloadEndpointTest extends EndpointTestAbstract
|
||||||
|
{
|
||||||
|
private function privateDisk(): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk(config('filesystems.private'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_with_attachment_disposition_serves_file_as_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$disk = $this->privateDisk();
|
||||||
|
$disk->put('exports/test-attachment.csv', 'Description,Duration');
|
||||||
|
$url = $disk->temporaryUrl('exports/test-attachment.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-attachment.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertDownload('test-attachment.csv');
|
||||||
|
$disk->delete('exports/test-attachment.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_without_options_serves_file_inline(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$disk = $this->privateDisk();
|
||||||
|
$disk->put('exports/test-inline.csv', 'Description,Duration');
|
||||||
|
$url = $disk->temporaryUrl('exports/test-inline.csv', now()->addMinutes(5));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertStringStartsWith('inline', (string) $response->headers->get('Content-Disposition'));
|
||||||
|
$disk->delete('exports/test-inline.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_with_tampered_disposition(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-tampered.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-tampered.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get(str_replace('attachment', 'inline', $url));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_with_expired_signature(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-expired.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-expired.csv"',
|
||||||
|
]);
|
||||||
|
$this->travel(6)->minutes();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_if_file_does_not_exist(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-missing.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-missing.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_of_private_local_disk_points_to_storage_route_with_signed_disposition(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertStringContainsString('/storage/exports/test-export.pdf', $url);
|
||||||
|
$this->assertStringContainsString('disposition=attachment', $url);
|
||||||
|
$this->assertStringContainsString('signature=', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_of_s3_disk_includes_content_disposition_from_options(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config([
|
||||||
|
'filesystems.disks.s3.key' => 'test-key',
|
||||||
|
'filesystems.disks.s3.secret' => 'test-secret',
|
||||||
|
'filesystems.disks.s3.region' => 'us-east-1',
|
||||||
|
'filesystems.disks.s3.bucket' => 'test-bucket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$url = Storage::disk('s3')->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||||
|
$this->assertSame('attachment; filename="test-export.pdf"', $query['response-content-disposition'] ?? null);
|
||||||
|
$this->assertArrayHasKey('X-Amz-Signature', $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
tests/Unit/Exceptions/Api/ApiExceptionTest.php
Normal file
96
tests/Unit/Exceptions/Api/ApiExceptionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/Unit/Exceptions/HandlerTest.php
Normal file
40
tests/Unit/Exceptions/HandlerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user