Files
projectsend/tests/Helpers.php
T
denkfabrik-li 61c385e423 Delete an account and dispose of its content in one transaction
Deleting a staff or client account is two writes: soft-delete the account,
then cascade or reassign the files and folders it owns. All four destroy()
paths (Users + Clients, web + API) ran them one after the other with nothing
tying them together.

If the second write throws, the account is already gone but its content is
not handled. The concrete way in is the reassign branch: validate() checks
reassign_to_id with exists(active), but apply() re-resolves it with
findOrFail() a moment later (AccountContentDeletion:108), so a target
deactivated or deleted in between throws — leaving a soft-deleted account
whose files still point at it, and a UserDeleted log for a deletion that did
not finish.

Wrap the delete()+apply() pair in a single DB::transaction() in each of the
four destroy() methods. validate() and the authorization guards stay outside
it: they are read-only and must be able to reject before anything is written.
cascadeDelete()/reassignTo() already open their own transaction, which nests
as a savepoint under this one, so the account soft-delete, its activity log,
and the content work now commit or roll back together.

Tests: a DeletedAccountContent double that reports content to handle and then
throws while handling it (tests/Helpers.php) drives one test per destroy()
endpoint asserting the account survives the failure and no UserDeleted entry
is written; each goes red against the un-wrapped controller.
2026-08-25 23:03:13 +02:00

285 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
/*
|--------------------------------------------------------------------------
| Cross-file test helpers
|--------------------------------------------------------------------------
|
| Pest declares the functions in a test file as ordinary globals, so a helper
| written in one file is visible to every other file — but only once that
| first file has been loaded. Running the whole suite loads everything and
| hides the problem; running a single file, or anything with --filter, fails
| with "Call to undefined function".
|
| Helpers used by more than one test file therefore live here, loaded from
| tests/Pest.php so they exist no matter which files a given run touches. A
| helper used by exactly one file should stay in that file — this is for
| shared ones only, and moving a private helper here would just make it
| harder to find.
|
*/
/** A folder created through the real service, so path/depth invariants hold. */
function makeFolder(string $name, ?Folder $parent = null): Folder
{
return app(FolderService::class)->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');
}
});
}