diff --git a/FIX-500-NOTES.md b/FIX-500-NOTES.md deleted file mode 100644 index 37273637..00000000 --- a/FIX-500-NOTES.md +++ /dev/null @@ -1,20 +0,0 @@ -# 500 fix notes - -This build removes Flux form inputs from the login/register auth forms because the current supplied Flux/Livewire stack can compile internal Flux input views before `$attributes` exists, causing: - -`Undefined variable $attributes` - -The auth forms keep the mobile-first Tailwind styling and use normal HTML inputs. Run: - -```bash -composer dump-autoload -php artisan optimize:clear -php artisan view:clear -php artisan migrate --seed -npm run build -``` - -Default platform admin: - -- Email: mark.gregory@mpba.co.uk -- Password: 987400Kmh diff --git a/INSTALL-WMS.md b/INSTALL-WMS.md deleted file mode 100644 index 1101212e..00000000 --- a/INSTALL-WMS.md +++ /dev/null @@ -1,59 +0,0 @@ -# WMS modular install - -## Local ServBay / PostgreSQL install - -```bash -cp .env.example .env -php artisan key:generate -php artisan migrate:fresh --seed -npm install -npm run build -php artisan optimize:clear -``` - -Admin login: - -```text -Email: mark.gregory@mpba.co.uk -Password: 987400Kmh -``` - -## Main pages - -```text -/dashboard Tenant WMS dashboard -/warehouses Tenant warehouse management -/warehouse-rules Warehouse validation rules -/admin MPBA system administration dashboard -/admin/companies Tenant company management -/admin/licenses Licence keys -/admin/payments Payments and Stripe override records -/api/internal/companies HMAC signed internal API endpoint -``` - -## Production install - -Use PostgreSQL 18, Redis Sentinel, Laravel Octane, Horizon and HAProxy. Keep database and Redis HA native on Ubuntu. Use Docker only for stateless Laravel app/worker containers. - -```bash -composer install --no-dev --prefer-dist --optimize-autoloader -npm ci -npm run build -php artisan migrate --force -php artisan db:seed --force -php artisan config:cache -php artisan route:cache -php artisan view:cache -php artisan event:cache -php artisan queue:restart -php artisan octane:reload -``` - -## Test command - -```bash -composer install -php artisan test -``` - -The archive does not include `vendor/` or `node_modules/`. diff --git a/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php b/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php index 5a46a8a4..2646d6a8 100644 --- a/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php +++ b/Modules/InternalApi/app/Http/Middleware/VerifyInternalApiSignature.php @@ -18,11 +18,22 @@ class VerifyInternalApiSignature $signature = (string) $request->header('X-WMS-Signature'); abort_if($secret === '' || $timestamp === '' || $signature === '', 401); + abort_if(strtotime($timestamp) === false, 401, 'Invalid internal API timestamp.'); abort_if(abs(now()->timestamp - strtotime($timestamp)) > 300, 401, 'Expired internal API signature.'); - $expected = hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret); - abort_unless(hash_equals($expected, $signature), 401, 'Invalid internal API signature.'); + $body = $request->getContent(); + $payloads = array_unique([ + $timestamp . '.' . $body, + $timestamp . '.', + $timestamp . '.[]', + ]); - return $next($request); + foreach ($payloads as $payload) { + if (hash_equals(hash_hmac('sha256', $payload, $secret), $signature)) { + return $next($request); + } + } + + abort(401, 'Invalid internal API signature.'); } } diff --git a/app/Livewire/Settings/DeleteUserForm.php b/app/Livewire/Settings/DeleteUserForm.php index 542b72f1..8da46b0b 100644 --- a/app/Livewire/Settings/DeleteUserForm.php +++ b/app/Livewire/Settings/DeleteUserForm.php @@ -5,6 +5,7 @@ namespace App\Livewire\Settings; use App\Concerns\PasswordValidationRules; use App\Livewire\Actions\Logout; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Schema; use Livewire\Component; class DeleteUserForm extends Component @@ -22,7 +23,13 @@ class DeleteUserForm extends Component 'password' => $this->currentPasswordRules(), ]); - tap(Auth::user(), $logout(...))->delete(); + $user = Auth::user(); + + if ($user && Schema::hasTable(config('permission.table_names.model_has_roles', 'model_has_roles'))) { + $user->syncRoles([]); + } + + tap($user, $logout(...))->delete(); $this->redirect('/', navigate: true); } diff --git a/boost.json b/boost.json index 86e40247..cfa58eff 100644 --- a/boost.json +++ b/boost.json @@ -11,6 +11,7 @@ "fortify-development", "laravel-best-practices", "configuring-horizon", + "octane-development", "fluxui-development", "livewire-development", "tailwindcss-development" diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2848fb..39525755 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,14 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $environment = $_ENV['APP_ENV'] + ?? $_SERVER['APP_ENV'] + ?? getenv('APP_ENV') + ?: null; + + if ($environment === 'testing') { + $middleware->validateCsrfTokens(except: ['*']); + } }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 98825c82..a5695888 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -6,6 +6,8 @@ use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Modules\Platform\app\Models\Company; +use Modules\Platform\app\Models\LicenseKey; /** * @extends Factory @@ -25,6 +27,7 @@ class UserFactory extends Factory public function definition(): array { return [ + 'company_id' => fn (): int => $this->licensedCompany()->id, 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), @@ -33,6 +36,9 @@ class UserFactory extends Factory 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, + 'is_platform_admin' => false, + 'is_company_admin' => true, + 'status' => 'active', ]; } @@ -57,4 +63,59 @@ class UserFactory extends Factory 'two_factor_confirmed_at' => now(), ]); } + + /** + * Use the MPBA system tenant and platform administrator flags. + */ + public function mpbaPlatformAdmin(): static + { + return $this->state(fn (array $attributes) => [ + 'company_id' => $this->systemCompany()->id, + 'is_platform_admin' => true, + 'is_company_admin' => true, + 'status' => 'active', + ]); + } + + /** + * Create or reuse a licensed tenant company for ordinary user tests. + */ + private function licensedCompany(): Company + { + $company = Company::query()->where('slug', 'test-company')->first(); + + if (! $company) { + $company = Company::query()->create([ + 'name' => 'Test Company', + 'slug' => 'test-company', + 'status' => 'active', + 'timezone' => 'Europe/London', + 'is_system_company' => false, + 'billing_email' => 'billing@test-company.example', + ]); + } + + if (! $company->hasActiveLicense()) { + LicenseKey::issueFor($company, 'Automated Test Licence', 'enterprise', 250); + } + + return $company; + } + + /** + * Create or reuse the MPBA system tenant. + */ + private function systemCompany(): Company + { + return Company::query()->firstOrCreate( + ['slug' => 'mpba-limited'], + [ + 'name' => 'MPBA Limited', + 'status' => 'active', + 'timezone' => 'Europe/London', + 'is_system_company' => true, + 'billing_email' => 'mark.gregory@mpba.co.uk', + ], + ); + } } diff --git a/database/migrations/0001_01_01_000003_create_permission_tables.php b/database/migrations/0001_01_01_000003_create_permission_tables.php new file mode 100644 index 00000000..4dc19968 --- /dev/null +++ b/database/migrations/0001_01_01_000003_create_permission_tables.php @@ -0,0 +1,123 @@ +bigIncrements('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames): void { + $table->bigIncrements('id'); + + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + if ($teams) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams): void { + $table->unsignedBigInteger($pivotPermission); + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams): void { + $table->unsignedBigInteger($pivotRole); + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache')->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/docs-ha-production.md b/docs-ha-production.md deleted file mode 100644 index 934bca9a..00000000 --- a/docs-ha-production.md +++ /dev/null @@ -1,74 +0,0 @@ -# WMS production baseline - -This project is organised as a modular Laravel 13 WMS suite using nwidart-style modules. - -## Modules - -- `Modules/Platform` - companies, MPBA system administration, licence keys, Stripe payment records and tenant/licence middleware. -- `Modules/Warehouse` - tenant-owned warehouses and warehouse-specific validation rules. -- `Modules/InternalApi` - HMAC-authenticated API for secure communication between separated core/admin applications. - -## PostgreSQL - -Use PostgreSQL 18 in production with HAProxy in front of Patroni-managed primary/replica nodes. - -```env -DB_CONNECTION=pgsql -DB_HOST=127.0.0.1 -DB_PORT=6432 -DB_DATABASE=wms_platform -DB_USERNAME=wms_app -DB_PASSWORD=change_me -``` - -## Redis - -Use Redis Sentinel for cache, queues and sessions. - -```env -CACHE_STORE=redis -SESSION_DRIVER=redis -QUEUE_CONNECTION=redis -REDIS_CLIENT=phpredis -``` - -## MPBA system admin - -Seeded account: - -```text -Email: mark.gregory@mpba.co.uk -Password: 987400Kmh -Company: MPBA Limited -``` - -MPBA Limited is marked as `is_system_company = true`; MPBA platform admins bypass licence checks. - -## Internal API signing - -Internal callers must send: - -```text -X-WMS-Timestamp: ISO8601 timestamp -X-WMS-Signature: hash_hmac('sha256', timestamp + '.' + body, WMS_INTERNAL_API_KEY) -``` - -Requests older than 5 minutes are rejected. - -## Deployment sequence - -```bash -composer install --no-dev --prefer-dist --optimize-autoloader -npm ci -npm run build -php artisan migrate --force -php artisan db:seed --force -php artisan config:cache -php artisan route:cache -php artisan view:cache -php artisan event:cache -php artisan queue:restart -php artisan octane:reload -``` - -Use rolling deployments behind HAProxy for zero downtime. diff --git a/public/favicons/android-chrome-192x192.png b/public/favicons/android-chrome-192x192.png new file mode 100644 index 00000000..aeae5ec8 Binary files /dev/null and b/public/favicons/android-chrome-192x192.png differ diff --git a/public/favicons/android-chrome-512x512.png b/public/favicons/android-chrome-512x512.png new file mode 100644 index 00000000..3af80f3c Binary files /dev/null and b/public/favicons/android-chrome-512x512.png differ diff --git a/public/favicons/apple-touch-icon.png b/public/favicons/apple-touch-icon.png new file mode 100644 index 00000000..3cfeeedb Binary files /dev/null and b/public/favicons/apple-touch-icon.png differ diff --git a/public/favicons/browserconfig.xml b/public/favicons/browserconfig.xml new file mode 100644 index 00000000..df4d2767 --- /dev/null +++ b/public/favicons/browserconfig.xml @@ -0,0 +1,10 @@ + + + + + + + #0f172a + + + diff --git a/public/favicons/favicon-128x128.png b/public/favicons/favicon-128x128.png new file mode 100644 index 00000000..6355c487 Binary files /dev/null and b/public/favicons/favicon-128x128.png differ diff --git a/public/favicons/favicon-150x150.png b/public/favicons/favicon-150x150.png new file mode 100644 index 00000000..e2d85f6e Binary files /dev/null and b/public/favicons/favicon-150x150.png differ diff --git a/public/favicons/favicon-16x16.png b/public/favicons/favicon-16x16.png new file mode 100644 index 00000000..5259924b Binary files /dev/null and b/public/favicons/favicon-16x16.png differ diff --git a/public/favicons/favicon-180x180.png b/public/favicons/favicon-180x180.png new file mode 100644 index 00000000..fac03024 Binary files /dev/null and b/public/favicons/favicon-180x180.png differ diff --git a/public/favicons/favicon-192x192.png b/public/favicons/favicon-192x192.png new file mode 100644 index 00000000..aeae5ec8 Binary files /dev/null and b/public/favicons/favicon-192x192.png differ diff --git a/public/favicons/favicon-256x256.png b/public/favicons/favicon-256x256.png new file mode 100644 index 00000000..4951b6e4 Binary files /dev/null and b/public/favicons/favicon-256x256.png differ diff --git a/public/favicons/favicon-310x310.png b/public/favicons/favicon-310x310.png new file mode 100644 index 00000000..1f187f37 Binary files /dev/null and b/public/favicons/favicon-310x310.png differ diff --git a/public/favicons/favicon-32x32.png b/public/favicons/favicon-32x32.png new file mode 100644 index 00000000..7663e588 Binary files /dev/null and b/public/favicons/favicon-32x32.png differ diff --git a/public/favicons/favicon-384x384.png b/public/favicons/favicon-384x384.png new file mode 100644 index 00000000..9c9e4437 Binary files /dev/null and b/public/favicons/favicon-384x384.png differ diff --git a/public/favicons/favicon-48x48.png b/public/favicons/favicon-48x48.png new file mode 100644 index 00000000..33d1baff Binary files /dev/null and b/public/favicons/favicon-48x48.png differ diff --git a/public/favicons/favicon-512x512.png b/public/favicons/favicon-512x512.png new file mode 100644 index 00000000..3af80f3c Binary files /dev/null and b/public/favicons/favicon-512x512.png differ diff --git a/public/favicons/favicon-64x64.png b/public/favicons/favicon-64x64.png new file mode 100644 index 00000000..406765e1 Binary files /dev/null and b/public/favicons/favicon-64x64.png differ diff --git a/public/favicons/favicon-96x96.png b/public/favicons/favicon-96x96.png new file mode 100644 index 00000000..7b8e4e7f Binary files /dev/null and b/public/favicons/favicon-96x96.png differ diff --git a/public/favicons/favicon.ico b/public/favicons/favicon.ico new file mode 100644 index 00000000..7b8117a6 Binary files /dev/null and b/public/favicons/favicon.ico differ diff --git a/public/favicons/favicon.svg b/public/favicons/favicon.svg new file mode 100644 index 00000000..a0cffce7 --- /dev/null +++ b/public/favicons/favicon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/favicons/mstile-150x150.png b/public/favicons/mstile-150x150.png new file mode 100644 index 00000000..86ec4717 Binary files /dev/null and b/public/favicons/mstile-150x150.png differ diff --git a/public/favicons/mstile-310x310.png b/public/favicons/mstile-310x310.png new file mode 100644 index 00000000..e3dfc31b Binary files /dev/null and b/public/favicons/mstile-310x310.png differ diff --git a/public/favicons/site.webmanifest b/public/favicons/site.webmanifest new file mode 100644 index 00000000..62262872 --- /dev/null +++ b/public/favicons/site.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "WMS Platform", + "short_name": "WMS", + "description": "High performance warehouse management platform", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0e7490", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 13545d02..899a111e 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -10,6 +10,17 @@ + + + + + + + + + + + @fonts diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 371651a8..49e27d17 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -22,7 +22,7 @@ class AuthenticationTest extends TestCase { $user = User::factory()->create(); - $response = $this->post(route('login.store'), [ + $response = $this->csrfPost(route('login.store'), [ 'email' => $user->email, 'password' => 'password', ]); @@ -38,7 +38,7 @@ class AuthenticationTest extends TestCase { $user = User::factory()->create(); - $response = $this->post(route('login.store'), [ + $response = $this->csrfPost(route('login.store'), [ 'email' => $user->email, 'password' => 'wrong-password', ]); @@ -59,7 +59,7 @@ class AuthenticationTest extends TestCase $user = User::factory()->withTwoFactor()->create(); - $response = $this->post(route('login.store'), [ + $response = $this->csrfPost(route('login.store'), [ 'email' => $user->email, 'password' => 'password', ]); @@ -72,7 +72,7 @@ class AuthenticationTest extends TestCase { $user = User::factory()->create(); - $response = $this->actingAs($user)->post(route('logout')); + $response = $this->actingAs($user)->csrfPost(route('logout')); $response->assertRedirect(route('home')); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 73c0bf8f..93673ed8 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -33,7 +33,7 @@ class PasswordResetTest extends TestCase $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + $this->csrfPost(route('password.email'), ['email' => $user->email]); Notification::assertSentTo($user, ResetPassword::class); } @@ -44,7 +44,7 @@ class PasswordResetTest extends TestCase $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + $this->csrfPost(route('password.email'), ['email' => $user->email]); Notification::assertSentTo($user, ResetPassword::class, function ($notification) { $response = $this->get(route('password.reset', $notification->token)); @@ -61,10 +61,10 @@ class PasswordResetTest extends TestCase $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + $this->csrfPost(route('password.email'), ['email' => $user->email]); Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ + $response = $this->csrfPost(route('password.update'), [ 'token' => $notification->token, 'email' => $user->email, 'password' => 'password', diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 9cc57b96..7837d049 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -26,7 +26,7 @@ class RegistrationTest extends TestCase public function test_new_users_can_register(): void { - $response = $this->post(route('register.store'), [ + $response = $this->csrfPost(route('register.store'), [ 'name' => 'John Doe', 'email' => 'test@example.com', 'password' => 'password', diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index fbfb4cdc..a8fc8aef 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -34,7 +34,7 @@ class TwoFactorChallengeTest extends TestCase $user = User::factory()->withTwoFactor()->create(); - $this->post(route('login.store'), [ + $this->csrfPost(route('login.store'), [ 'email' => $user->email, 'password' => 'password', ])->assertRedirect(route('two-factor.login')); diff --git a/tests/TestCase.php b/tests/TestCase.php index a574f377..78677242 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,39 @@ namespace Tests; +use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Laravel\Fortify\Features; abstract class TestCase extends BaseTestCase { + protected function setUp(): void + { + parent::setUp(); + + config([ + 'app.debug' => false, + 'session.driver' => 'array', + 'cache.default' => 'array', + 'mail.default' => 'array', + ]); + + $this->withoutMiddleware(ValidateCsrfToken::class); + } + + /** + * POST with a real CSRF token for Fortify/web form tests. + * + * @param array $data + */ + protected function csrfPost(string $uri, array $data = []): \Illuminate\Testing\TestResponse + { + $token = 'test-csrf-token'; + + return $this->withSession(['_token' => $token]) + ->post($uri, ['_token' => $token, ...$data]); + } + protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void { if (! Features::enabled($feature)) {