create($name, $parent); } /** A staff user whose role has exactly the given permission keys. */ function staffWithPermissions(array $permissions): User { $role = Role::query()->create(['name' => 'Role '.Str::random(6)]); foreach ($permissions as $permission) { RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]); } return User::factory()->create(['role_id' => $role->id]); } /** * Clear the password.confirm gate by actually confirming the password, * rather than stamping the session key directly — that way tests keep * exercising the real gate instead of asserting around it. */ function confirmPassword(User $user): void { test()->actingAs($user)->post('/confirm-password', ['password' => 'password'])->assertRedirect(); } /** * Enrol a user in two-factor authentication for real — start, confirm with * a live TOTP code, and return the secret so a test can generate more. */ function enableTwoFactor(User $user): string { confirmPassword($user); test()->actingAs($user)->post('/settings/two-factor'); $secret = $user->refresh()->two_factor_secret; assert($secret !== null); $code = app(Google2FA::class)->getCurrentOtp($secret); test()->actingAs($user)->post('/settings/two-factor/confirm', ['code' => $code]); expect($user->refresh()->hasTwoFactorEnabled())->toBeTrue(); // Forget the replay guard for the code consumed during enrollment so // tests can log in within the same TOTP time window. Cache::flush(); return $secret; } /** * Drop the per-request state the previous request memoised, so the next * one in the same test genuinely starts cold. * * A test process handles several requests against one application * instance. Two caches are deliberately request-scoped in production and * therefore leak across requests here: AuthManager holds the guard and the * User it resolved, and PermissionChecker is a singleton that memoises a * role's granted keys. A test that changes an account mid-test — * deactivating it, stripping a permission — and asserts on the *next* * request would otherwise be asserting against stale memory rather than * the database, and would pass no matter what the code did. */ function forgetRequestState(): void { app('auth')->forgetGuards(); app()->forgetInstance(PermissionChecker::class); } /** Bytes on disk with no File row pointing at them. */ function makeOrphanFile(string $path, string $content = 'hello-world', string $disk = 'files'): void { Storage::disk($disk)->put($path, $content); } /** A complete, valid payload for the email settings form. */ function validEmailSettingsPayload(array $overrides = []): array { return array_merge([ 'email_notifications_enabled' => true, 'admin_notification_emails' => ['admin@example.com'], 'provider' => 'custom', 'host' => 'smtp.example.test', 'port' => 587, 'username' => null, 'password' => null, 'encryption' => 'tls', 'from_address' => 'hello@example.com', 'from_name' => 'ProjectSend', ], $overrides); } /** Upload a real image through the intake endpoint and return its File row. */ function uploadImageFile(User $as, string $name = 'photo.jpg'): File { test()->actingAs($as)->post('/files', [ 'file' => UploadedFile::fake()->image($name, 200, 100), 'name' => '', 'description' => '', ]); return File::query()->latest('id')->firstOrFail(); } /** A named PDF document, for tests that assert on the name/description they set. */ function uploadDocumentFile(User $as, string $name = 'contract.pdf'): File { test()->actingAs($as)->post('/files', [ 'file' => UploadedFile::fake()->create($name, 512, 'application/pdf'), 'name' => '', 'description' => 'Test document', ]); return File::query()->latest('id')->firstOrFail(); } /** * A small PDF filed under an explicit display name and folder — for the * scoping tests, where which client can see which file is the point and the * bytes are irrelevant. */ function uploadNamedFile(User $as, string $name, ?int $folderId = null): File { test()->actingAs($as)->post('/files', [ 'file' => UploadedFile::fake()->create($name.'.pdf', 12, 'application/pdf'), 'name' => $name, 'description' => '', 'folder_id' => $folderId, ]); return File::query()->latest('id')->firstOrFail(); } /** * Assigns a file directly to a client, the row the assignments endpoint * would have written. Use this when a test needs the client to *have* * access; go through the endpoint when the sharing itself (activity, * notifications, permissions) is what's under test. */ function shareFileWith(File $file, User $client): void { FileAssignment::query()->create([ 'file_id' => $file->id, 'assignable_type' => $client->getMorphClass(), 'assignable_id' => $client->id, ]); } /** The group-keyed sibling of shareFileWith(), for public-listing cases. */ function shareFileWithGroup(File $file, Group $group): void { FileAssignment::query()->create([ 'file_id' => $file->id, 'assignable_type' => $group->getMorphClass(), 'assignable_id' => $group->id, ]); } /** Activates external storage (Community-only) for the rest of the test. */ function activateExternalStorage(): void { ExternalStorageSettings::current()->fill([ 'active' => true, 'key' => 'AKIAEXAMPLE', 'secret' => 'shh', 'bucket' => 'my-bucket', 'region' => 'us-east-1', ])->save(); app(ExternalStorageConfigApplier::class)->flush(); } /** * A public file row, with no bytes behind it — enough for any listing or * permission assertion that never opens the file. */ function publicListingFile(array $overrides = []): File { return File::factory()->create(array_merge([ 'uploaded_by' => User::factory()->create()->id, 'name' => 'Report', 'original_name' => 'report.pdf', 'path' => '2026/08/'.Str::uuid()->toString().'.pdf', 'mime_type' => 'application/pdf', 'size' => 2048, 'public' => true, ], $overrides)); } /** * A real, thumbnailable public image on the faked "files" disk — unlike * publicListingFile()'s bare PDF row, this one has actual bytes GD can * decode, needed to exercise the thumbnail-generation path. */ function publicListingImageFile(User $uploader): File { test()->actingAs($uploader)->post('/files', [ 'file' => UploadedFile::fake()->image('photo.jpg', 200, 100), 'name' => '', 'description' => '', ]); $file = File::query()->latest('id')->firstOrFail(); $file->update(['public' => true]); return $file; } /** * An id_token whose payload names a mailbox the OAuth mail flow connects * as — signature irrelevant, it is never verified. Shared between the * Microsoft and Gmail OAuth mail tests, which run in separate processes * under --parallel. */ function fakeIdToken(string $email = 'portal@example.test'): string { $encode = fn (array $claims): string => rtrim(strtr(base64_encode((string) json_encode($claims)), '+/', '-_'), '='); return $encode(['alg' => 'none']).'.'.$encode(['preferred_username' => $email]).'.sig'; } /** * Bind a DeletedAccountContent double that reports content to dispose of (so * a delete demands a choice) and then throws while carrying that choice out. * Lets the account-deletion tests prove the destroy() controllers roll their * soft-delete back when the content step fails, rather than stranding a * deleted account whose files still point at it. */ function failAccountContentDisposal(): void { app()->instance(DeletedAccountContent::class, new class extends DeletedAccountContent { public function summarize(User $user): array { return ['files' => 1, 'folders' => 0]; } public function reassignTo(User $from, User $to): array { throw new RuntimeException('content reassignment failed'); } }); } /** * Swap the update in for one that records its artisan calls instead of * running them, and return it. * * Used by every test that runs `projectsend:update`, in more than one file * — see the class for why running the real commands is not an option in a * parallel suite. * * @param array{route?: bool, event?: bool, config?: bool} $warm * @param array $exitCodes */ function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate { $fake = new RecordingUpdate( app(Application::class), app(EnsureSystemRoles::class), app(Settings::class), app(ActivityLogger::class), ); $fake->warm = [...$fake->warm, ...$warm]; $fake->exitCodes = $exitCodes; app()->instance(UpdateInstallation::class, $fake); return $fake; }