The pages were built five ways: two layouts, seven widths from 28 to 64rem and four heading styles. Every page now looks like the share pages: a centred heading over one 40rem column of outlined cards, with the floating toolbar below. - <x-page> (components/page.blade.php) is the root of every page. It draws the h1 and its line (`brand` takes the site's logo, title and description from Admin settings), an optional `mark` and `navigation` slot, then the content. It has no width prop: every page is the same <x-pane width="narrow">. - The sign-in, password reset, confirm, verify email, two-factor challenge, setup and system password pages move onto layouts/app with the brand heading and their form in a card titled with the task. layouts/auth, auth-header and the settings heading partial are gone, and so is the per-page width CSS. - Settings put their section nav under the heading; the admin pages get a description line each. FileUploader and ShareDownload no longer pass the branding to their views. - The admin dashboard's table needed about 49rem, so its shares are a list: created above the token, which opens the share, then files, size, downloads and expiry on two lines that wrap instead of clipping, and one delete button. A "Sort by" select replaces the column headers (newest, oldest, expiring soonest with never-expiring last, largest, most downloads, most files) and resets the page. The stats stay two by two. table.css and sort-header.css are no longer imported. - Branding hints in Admin settings name every page the title shows on. - Tests: PageTemplateTest renders every page once and checks one page template, one h1 and the width, and the brand heading with its fallbacks. FrameTest measures the page column instead of the auth card and the 64rem main; dashboard tests follow the list and the sort select, including expiry order. .ai/rules/views.md records <x-page>, the CHANGELOG notes the change and the website screenshots are regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
12 KiB
PHP
233 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\Share;
|
|
use App\Models\User;
|
|
use App\Services\FileEncryptionService;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
|
|
|
beforeEach(function () {
|
|
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
});
|
|
|
|
test('files dragged over the drop zone turn its shape into a burst', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity";
|
|
|
|
$page->assertScript("{$burst} === '0'");
|
|
|
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")");
|
|
|
|
$page->assertScript("{$burst} === '1'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('a chosen file is encrypted in the browser, sent in chunks and shared with its exact content', function () {
|
|
// Pest's in-process server takes request bodies up to 128 KB: 64 KB chunks send this file in three.
|
|
config(['uploads.chunk_size' => 64 * 1024]);
|
|
$content = random_bytes(150 * 1024);
|
|
$path = sys_get_temp_dir().'/sealshare-browser-upload-'.uniqid().'.bin';
|
|
file_put_contents($path, $content);
|
|
|
|
$page = ready(visit('/upload'));
|
|
$page->attach('[data-test="file-input"]', $path)
|
|
->waitForText('Uploaded')
|
|
->click('[data-test="create-share"]')
|
|
->waitForText('Share Created!')
|
|
->assertNoJavaScriptErrors();
|
|
|
|
$file = Share::query()->sole()->files->sole();
|
|
expect($file->uploaded_chunks)->toBe(3);
|
|
$stored = app(FileEncryptionService::class)->decryptedChunks(app(ShareService::class)->storedFilePath($file), $file->share->encryption_key);
|
|
expect(implode('', iterator_to_array($stored, false)))->toBe($content);
|
|
|
|
unlink($path);
|
|
});
|
|
|
|
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
|
[],
|
|
);
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->assertSee('Share Created!')
|
|
->assertScript("document.querySelector('[data-test=\"share-link\"]').value.includes('/s/')");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-md-field-copy]')
|
|
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
|
->assertSee('Copied to the clipboard');
|
|
});
|
|
|
|
test('an uploader generates a share password and copies it from the upload page', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$page->click('label:has-text("Password protect")')
|
|
->click('[data-test="generate-password"]')
|
|
->assertScript("/^[A-Za-z0-9]{20}$/.test(document.querySelector('input[wire\\\\:model=\"password\"]').value)");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-test="copy-password"]')
|
|
->assertScript("window.copied === document.querySelector('input[wire\\\\:model=\"password\"]').value")
|
|
->assertSee('Copied to the clipboard')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the new share\'s password stays masked on the page and is copied without being shown', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
|
['password' => 'violet-orbit-canyon'],
|
|
);
|
|
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]);
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$field = "document.querySelector('[data-test=share-password]')";
|
|
$page->assertScript("{$field}.type === 'password'")
|
|
->assertScript("{$field}.value === 'violet-orbit-canyon'");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-md-field]:has([data-test="share-password"]) [data-md-field-copy]')
|
|
->assertScript("window.copied === 'violet-orbit-canyon'")
|
|
->assertScript("{$field}.type === 'password'")
|
|
->assertSee('Copied to the clipboard')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
|
$share = Share::factory()->withPassword()->create();
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->click('[data-test="show-qr-code"]')
|
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
|
// The white field and quiet zone are baked into the SVG itself (App\Services\QrCodeService),
|
|
// not a background colour on its container, so a scanner keeps its contrast in dark mode too.
|
|
->assertScript("document.querySelector('[data-qr-code] svg rect').getAttribute('fill') === '#ffffff'")
|
|
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
|
->assertSee('Recipients also need the password.');
|
|
|
|
// Record what would be saved instead of saving it.
|
|
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
|
|
|
|
$page->click('[data-test="download-qr-code"]')
|
|
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
|
|
|
|
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
|
|
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
|
|
|
|
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
|
|
->assertScript("window.eval('window.png.width') === 1024")
|
|
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
|
|
$share = Share::factory()->create();
|
|
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
// Shown only where the browser has a share sheet.
|
|
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
|
|
|
|
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
|
|
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
|
|
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
|
|
$page->assertSee('The share sheet could not open.');
|
|
});
|
|
|
|
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
|
|
['password' => 'correct horse'],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
|
|
|
$page->assertSee('Password Required')
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors()
|
|
->type('input[type="password"]', 'correct horse')
|
|
->press('Unlock')
|
|
->assertSee('Shared Files')
|
|
->assertSee('holiday-photos.zip')
|
|
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
|
});
|
|
|
|
test('an admin sorts the shares list and deletes a share through its dialog', function () {
|
|
$admin = User::factory()->admin()->create();
|
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
|
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
|
|
|
$this->actingAs($admin);
|
|
|
|
$page = ready(visit('/admin/dashboard'));
|
|
|
|
$page->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'aaaaaaaaaaaaaaaa'")
|
|
->select('[data-test="shares-sort"]', 'most-downloaded')
|
|
->wait(0.5)
|
|
->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
|
|
|
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->click('[data-test="confirm-delete-share"]')
|
|
->assertScript("! [...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->assertDontSee('zzzzzzzzzzzzzzzz');
|
|
|
|
expect(Share::query()->find($doomed->id))->toBeNull();
|
|
});
|
|
|
|
test('a first visit follows the system theme, and Appearance switches it', function () {
|
|
ready(visit('/upload')->inDarkMode())
|
|
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
|
->assertScript("document.documentElement.dataset.themeChoice === 'system'");
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$page = ready(visit('/settings/appearance')->inDarkMode());
|
|
|
|
$page->click('label:has(input[name="material-theme"][value="light"])')
|
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
|
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
|
});
|
|
|
|
test('an admin previews a colour profile, saves it, and every page wears it', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
|
|
$page = ready(visit('/admin/settings'));
|
|
|
|
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
|
->click('[data-test="color-profile"] [data-md-scheme-picker-option="teal"]')
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
|
|
|
expect(Setting::get('color_profile'))->toBeNull();
|
|
|
|
$page->click('[data-test="save-settings"]')
|
|
->assertSee('Settings saved successfully.');
|
|
|
|
expect(Setting::get('color_profile'))->toBe('teal');
|
|
|
|
ready(visit('/upload'))
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
|
->assertScript("getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim() === '".Scheme::profiles()['teal']['light']['primary']."'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|