mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
4469648d82
`UpdateWelcomeTest > staff who may not read system information are not
interrupted` fails on a parallel run roughly one time in six, with
BindingResolutionException: Target [Inertia\Ssr\Gateway] is not
instantiable
in a file that has nothing to do with updates. Run alone it is green
every time. The cause is not in that file.
`clear-compiled` deletes bootstrap/cache/packages.php and
bootstrap/cache/services.php. There is one of each for the whole
checkout, and `pest --parallel` gives eight worker processes the same
one. Instrumented over three full runs, the real command ran 12 times per
run -- 11 from UpdateCommandTest, 1 from StaleCodeNoticeTest -- and the
other workers observed the package manifest missing at boot 46 times.
What that costs is in PackageManifest::getManifest():
if (! is_file($this->manifestPath)) {
$this->build();
}
return $this->manifest = is_file($this->manifestPath) ?
$this->files->getRequire($this->manifestPath) : [];
A worker that loses the second is_file() to another worker's unlink gets
`[]`: no discovered packages, so no package service providers, so
Inertia's is never registered and `Inertia\Ssr\Gateway` is never bound.
The next page it renders dies in the compiled root view, where
`@inertia` resolves that interface. Any test in any file, whichever one
happened to be booting.
Both halves measured. Building the manifest with inertia-laravel in
`dont-discover` reproduces the reported failure exactly -- same test,
same exception, same frame (`app('Inertia\Ssr\Gateway')` from the
compiled app.blade.php). And 12 real `clear-compiled` calls per run is
the count above.
UpdateCommandTest already owns a double for this, and says why in its own
docblock: the artisan call is a seam. Nine of its tests and one in
StaleCodeNoticeTest simply do not use it. None of them asserts that a
command ran -- they assert EnsureSystemRoles, the settings writes, the
activity log and the welcome marker, and the double touches none of
those. So the seam now covers the file, through a beforeEach rather than
per test, because the next test added here should not have to know any of
this.
The double moves to tests/Support and its helper to tests/Helpers.php,
for the reason that file documents: Pest hands whole files to workers, so
a class declared in one test file does not exist for another.
Not changed: UpdateInstallation. `clear-compiled` belongs in a real
update. Also not changed: giving each worker its own bootstrap/cache
through APP_PACKAGES_CACHE and friends. That would make the destruction
cheap rather than remove it, and nothing in the suite needs those
commands to run at all.
One new test, on the files rather than on the recorded call list -- a
future double that forgot to intercept one command would still satisfy a
call-list assertion. Counter-checked: with the beforeEach removed it goes
red on both manifests being gone (1 failed / 22 passed).
Eight consecutive parallel runs green after the change; the manifests'
mtimes are untouched by a full run, where before they were rewritten
every time. Full suite passes (2049 passed / 2 skipped). PHPStan level 8
clean -- it analyses `app` only, so it does not cover this change.
Pre-existing and left alone: pint reports `ordered_imports` on
UpdateCommandTest.php. Its import block is misordered on main too.
319 lines
11 KiB
PHP
319 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
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\EnsureSystemRoles;
|
|
use App\Modules\Identity\Permissions\PermissionChecker;
|
|
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
|
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Modules\Platform\Updates\UpdateInstallation;
|
|
use Illuminate\Contracts\Foundation\Application;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
use Tests\Support\RecordingUpdate;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| 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');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<string, int> $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;
|
|
}
|