Files
ignacionelson 38400956bc Put the installation's own logo on the pages people sign in through
Requested by @Zodiac1978 in #1777.

The logo already replaced ours in the staff sidebar, on the public listing
and in the client portal. The sign-in screen still wore the ProjectSend
wordmark — and that is the first page of yours most people ever see, and
often the only one a client sees, because it is where the link in a
notification email lands them.

One layout serves every screen reached before signing in, so this covers
login, registration, both password-reset pages, the two-factor challenge,
first-run setup and the page a share link opens. That breadth is the reason
to change the layout rather than the login page: the same visitor moves
between several of them in one sitting, and a logo that appeared on one and
not the next would read as a different site.

Nothing needed gating. `branding.logo_url` is already shared on every
request and is already null wherever the Branding capability is absent, so
an installation that has withheld branding, or never uploaded anything,
renders exactly what it rendered before.

Three tests cover the server's half — the prop reaching a page nobody has
signed in to see, the null fallback, and the capability being taken away.
None of them can say whether the component mounted or the image resolved,
so that was checked in a real browser: headless Chrome against the dev
instance with a logo installed reports the <img> present, naturalWidth 360
(so it decoded rather than sitting broken) and a rendered height of 48px,
and with the logo removed reports no <img> and the fallback SVG in its
place. The branding row was snapshotted before and restored after.

One thing worth knowing, unchanged by this and not introduced by it: a logo
drawn for a white background is hard to read on the dark theme, here and on
every other surface that shows it, because none of them filter the artwork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:00:38 -03:00

220 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route as RouteInstance;
use Illuminate\Support\Facades\Route;
use Inertia\Testing\AssertableInertia;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use App\Modules\Platform\Branding\Models\BrandingSetting;
beforeEach(function () {
Storage::fake('public');
$this->actingAs(staffWithPermissions(['edit_settings']));
});
test('a staff member can upload a logo, and it is reflected in the shared branding prop', function () {
$this->get(route('branding.edit'))->assertOk();
$this->post(route('branding.store'), [
'logo' => UploadedFile::fake()->image('logo.png', 200, 80),
])->assertRedirect();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_path)->not->toBeNull();
Storage::disk('public')->assertExists($setting->logo_path);
});
test('uploading a new logo replaces and deletes the previous one', function () {
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('first.png')]);
$firstPath = BrandingSetting::query()->sole()->logo_path;
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('second.png')]);
$secondPath = BrandingSetting::query()->sole()->logo_path;
expect($secondPath)->not->toBe($firstPath);
Storage::disk('public')->assertMissing($firstPath);
Storage::disk('public')->assertExists($secondPath);
});
test('a non-image upload is rejected', function () {
$this->post(route('branding.store'), [
'logo' => UploadedFile::fake()->create('not-an-image.pdf', 10, 'application/pdf'),
])->assertSessionHasErrors('logo');
});
test('removing the logo deletes the file and clears the setting', function () {
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
$path = BrandingSetting::query()->sole()->logo_path;
$this->delete(route('branding.destroy'))->assertRedirect();
expect(BrandingSetting::query()->sole()->logo_path)->toBeNull();
Storage::disk('public')->assertMissing($path);
});
test('a client cannot reach the branding screen', function () {
// The route group carries `staff`, so this is refused before any
// permission is consulted: a client account holding edit_settings
// somehow would still not be looking at system settings.
$this->actingAs(User::factory()->client()->create());
// Redirected rather than 403'd: core's `staff` middleware sends a
// client to their own portal rather than telling them a staff screen
// exists. The package's isolated harness had no portal to send them
// to, which is why this read as a 403 there.
$this->get(route('branding.edit'))->assertRedirect();
});
test('the route 404s when the capability has been taken away', function () {
// How a hosted plan withholds branding: the key is subtracted from
// the instance's environment and the screen stops existing. The
// instance refusing is the enforcement — a portal hiding its own
// button is presentation.
config(['projectsend.capabilities_disabled' => 'branding.customize']);
$this->get(route('branding.edit'))->assertNotFound();
});
// The row can outlive the capability: an installation moved to the
// community edition, or a backup restored into one. The screen that would
// take the logo off is 404 there, so a share that still answered would put
// a logo on every page with no way to remove it.
test('the shared logo goes away with the capability, whatever the row says', function () {
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
expect(BrandingSetting::query()->sole()->logo_path)->not->toBeNull();
$shared = fn (): ?string => value(Inertia::getShared('branding'))['logo_url'];
expect($shared())->not->toBeNull();
// Taken away the way a hosted plan takes it away. The row is left
// exactly as it was: a downgrade is usually an expired card rather
// than a decision, and deleting somebody's branding over a billing
// event is a loss they would find weeks later with no way to know
// what it used to be. Hiding reverses; deleting does not.
config(['projectsend.capabilities_disabled' => 'branding.customize']);
forgetRequestState();
expect($shared())->toBeNull()
->and(BrandingSetting::query()->sole()->logo_path)->not->toBeNull();
});
// The `image` rule only inspects sniffed content, so a real GIF named
// ".html" passes it. This disk is web-served (public/storage is symlinked
// into the document root), so honouring the uploaded filename's extension
// would store — and serve back — script on the app's own origin.
//
// Built as a real UploadedFile rather than UploadedFile::fake(): the fake
// derives its mime type from the *name*, so it cannot express the very
// split this guards against (content says GIF, filename says HTML).
test('the stored extension comes from the content, never from the uploaded filename', function () {
$temp = tempnam(sys_get_temp_dir(), 'polyglot');
file_put_contents($temp, "GIF89a=1;\n<html><body><script>alert(document.domain)</script></body></html>");
$polyglot = new UploadedFile($temp, 'logo.html', 'image/gif', null, true);
// Precondition: this really does pass validation — the fix is about
// what happens next, not about rejecting the upload.
expect($polyglot->getClientOriginalExtension())->toBe('html')
->and($polyglot->guessExtension())->toBe('gif');
$this->post(route('branding.store'), ['logo' => $polyglot])->assertRedirect();
$path = BrandingSetting::query()->sole()->logo_path;
expect($path)->not->toBeNull()
->and($path)->not->toEndWith('.html')
->and(pathinfo($path, PATHINFO_EXTENSION))->toBe('gif');
Storage::disk('public')->assertExists($path);
@unlink($temp);
});
// The logo is a system setting, so it takes the same permission as every
// other settings surface. `staff` alone previously let any staff role —
// an Uploader, a Client Manager — replace or delete it.
test('a staff member without edit_settings cannot see or change the logo', function () {
$this->actingAs(staffWithPermissions([]));
$this->get(route('branding.edit'))->assertForbidden();
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')])->assertForbidden();
$this->delete(route('branding.destroy'))->assertForbidden();
expect(BrandingSetting::query()->count())->toBe(0);
});
/*
|--------------------------------------------------------------------------
| The half that did not move
|--------------------------------------------------------------------------
|
| Hiding "Powered by ProjectSend" stayed in cloud-modules. This screen
| renders the switch, and nothing here can save it: the route that does is
| registered by the package, and an installation without the package has
| the column and no code able to act on it.
*/
test('the screen carries the attribution value it cannot change', function () {
// Written directly, because core has no way to set it — which is the
// point. The value still has to reach the page, or a hosted customer
// would open the tab and find the switch always off.
BrandingSetting::current()->forceFill(['hide_attribution' => true])->save();
$this->get(route('branding.edit'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('hide_attribution', true));
});
test('core has no route that can change it', function () {
// The gate is the absence of the code, not a capability check. If this
// ever passes, the white-label feature has quietly become free.
expect(collect(Route::getRoutes()->getRoutes())
->contains(fn (RouteInstance $route): bool => str_contains($route->uri(), 'branding/attribution')))
->toBeFalse();
});
// The sign-in screens, requested in #1777. One layout serves all of them —
// login, registration, the reset pair, the two-factor challenge, first-run
// setup and the page a share link opens — so what is asserted here is that
// the prop reaches a page nobody has signed in to see, which is the part
// the layout could not do for itself.
test('a guest at the sign-in screen is given the branding logo', function () {
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
$logoUrl = BrandingSetting::query()->sole()->logoUrl();
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', $logoUrl),
);
});
test('the sign-in screen falls back to the product logo when nothing was uploaded', function () {
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', null),
);
});
test('a withheld capability takes the logo off the sign-in screen too', function () {
// The screen that would remove it 404s without the capability, so a
// login page still wearing somebody's logo would have no way back.
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
config(['projectsend.capabilities_disabled' => 'branding.customize']);
forgetRequestState();
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->where('branding.logo_url', null),
);
});