get('https://presseecho.test/de') ->assertSuccessful() ->assertHeader('X-Content-Type-Options', 'nosniff') ->assertHeader('X-Frame-Options', 'SAMEORIGIN') ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin') ->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); }); test('the magic link and preview consume routes are throttled', function () { $routes = Route::getRoutes(); expect($routes->getByName('magic-links.consume')->gatherMiddleware())->toContain('throttle:10,1'); expect($routes->getByName('press-releases.preview')->gatherMiddleware())->toContain('throttle:30,1'); }); test('password reset requests are rate limited per email', function () { foreach (range(1, 3) as $i) { RateLimiter::hit('password-reset|spam@example.test', 3600); } Livewire::test('auth.forgot-password') ->set('email', 'spam@example.test') ->call('sendPasswordResetLink') ->assertHasErrors(['email']); }); test('registration is rate limited per ip', function () { foreach (range(1, 10) as $i) { RateLimiter::hit('register-ip|127.0.0.1', 3600); } Livewire::test('auth.register') ->set('name', 'Bot Bot') ->set('email', 'bot@example.test') ->set('password', 'super-secret-password') ->set('password_confirmation', 'super-secret-password') ->set('terms_accepted', true) ->call('register') ->assertHasErrors(['email']); expect(User::query()->where('email', 'bot@example.test')->exists())->toBeFalse(); }); test('company create stores the logo through the image service pipeline', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); Storage::fake('public'); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); Livewire::actingAs($admin) ->test('admin.companies.create') ->set('company_name', 'Logo GmbH') ->set('email', 'logo@example.test') ->set('portal', 'presseecho') ->set('logo', UploadedFile::fake()->image('logo.png', 400, 400)) ->call('save') ->assertHasNoErrors(); $company = Company::query()->where('name', 'Logo GmbH')->firstOrFail(); // ImageService-Pfad (UUID-Name unter company-logos/{portal}/{id}) statt // roher store()-Ablage; Varianten wurden erzeugt. expect($company->logo_path)->toStartWith('company-logos/presseecho/'.$company->id.'/'); expect($company->logo_variants)->not->toBeNull(); Storage::disk('public')->assertExists($company->logo_path); }); test('company create rejects svg logos', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); Storage::fake('public'); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); Livewire::actingAs($admin) ->test('admin.companies.create') ->set('company_name', 'SVG GmbH') ->set('portal', 'presseecho') ->set('logo', UploadedFile::fake()->createWithContent('logo.svg', '')) ->call('save') ->assertHasErrors(['logo']); expect(Company::query()->where('name', 'SVG GmbH')->exists())->toBeFalse(); }); test('https responses carry hsts only when it is enabled', function () { /** @var TestCase $this */ $this->get('https://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security'); config()->set('security.hsts', ['enabled' => true, 'max_age' => 31536000]); $this->get('https://presseecho.test/de')->assertHeader('Strict-Transport-Security', 'max-age=31536000'); $this->get('http://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security'); }); test('forwarded client ips are ignored unless the proxy is trusted', function () { /** @var TestCase $this */ Route::get('/_test/client-ip', fn () => request()->ip()); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) ->withHeader('X-Forwarded-For', '198.51.100.7') ->get('/_test/client-ip') ->assertContent('203.0.113.10'); config()->set('trustedproxy.proxies', '203.0.113.10'); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) ->withHeader('X-Forwarded-For', '198.51.100.7') ->get('/_test/client-ip') ->assertContent('198.51.100.7'); }); test('production trusts no proxy unless one is configured', function (string $appEnv, ?string $trustedProxies, ?string $expected) { $code = 'require "vendor/autoload.php"; echo json_encode((require "config/trustedproxy.php")["proxies"]);'; $result = Process::path(base_path()) ->env(array_filter(['APP_ENV' => $appEnv, 'TRUSTED_PROXIES' => $trustedProxies], fn ($value) => $value !== null) + ['TRUSTED_PROXIES' => false]) ->run([PHP_BINARY, '-r', $code]); expect(json_decode($result->throw()->output()))->toBe($expected); })->with([ 'live without setting' => ['production', null, null], 'local behind traefik' => ['local', null, '*'], 'live behind a cdn' => ['production', '10.0.0.0/8', '10.0.0.0/8'], 'explicitly empty' => ['local', '', null], ]); test('the test suite never sends real ntfy pushes', function () { expect(config('monitoring.alerts.ntfy.url'))->toBeEmpty() ->and(config('monitoring.alerts.ntfy.topic'))->toBeEmpty() ->and(config('monitoring.alerts.ntfy.token'))->toBeEmpty(); }); test('too many failed logins from one ip block further logins for every account', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); foreach (range(1, LoginIpThrottle::MAX_FAILURES) as $i) { Livewire::test('auth.login') ->set('email', "unbekannt{$i}@example.test") ->set('password', 'falsches-passwort') ->call('login') ->assertHasErrors(['email']); } // Richtige Zugangsdaten eines anderen Kontos helfen von dieser IP nicht. Livewire::test('auth.login') ->set('email', $user->email) ->set('password', 'password') ->call('login') ->assertHasErrors(['email']); $this->assertGuest(); }); test('the fortify login post shares the per-ip limit', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); foreach (range(1, LoginIpThrottle::MAX_FAILURES) as $i) { $this->postJson('/login', ['email' => "unbekannt{$i}@example.test", 'password' => 'falsch'])->assertUnprocessable(); } $this->postJson('/login', ['email' => $user->email, 'password' => 'password']) ->assertStatus(429) ->assertHeader('Retry-After'); $this->assertGuest(); }); test('failed logins below the per-ip limit do not block other accounts', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); $throttle = app(LoginIpThrottle::class); foreach (range(1, LoginIpThrottle::MAX_FAILURES - 1) as $i) { $throttle->recordFailure('127.0.0.1'); } Livewire::test('auth.login') ->set('email', $user->email) ->set('password', 'password') ->call('login') ->assertHasNoErrors(); $this->assertAuthenticatedAs($user); }); test('new passwords need at least twelve characters', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); $this->actingAs($user); Livewire::test('customer.security') ->set('current_password', 'password') ->set('password', 'kurz-12345') ->set('password_confirmation', 'kurz-12345') ->call('updatePassword') ->assertHasErrors(['password']); expect(Hash::check('password', $user->fresh()->password))->toBeTrue(); }); test('in production new passwords are checked against known leaks', function () { $password = 'korrekt-pferd-batterie'; $hash = strtoupper(sha1($password)); Http::fake(['api.pwnedpasswords.com/range/'.substr($hash, 0, 5) => Http::response(substr($hash, 5).":42\r\n")]); app()->instance('env', 'production'); try { expect(Validator::make(['password' => $password], ['password' => Password::defaults()])->fails())->toBeTrue() ->and(Validator::make(['password' => 'ein-anderes-passwort'], ['password' => Password::defaults()])->fails())->toBeFalse(); } finally { app()->instance('env', 'testing'); } }); test('html responses carry a report-only content security policy when enabled', function () { /** @var TestCase $this */ $this->get('https://presseecho.test/de')->assertHeaderMissing('Content-Security-Policy-Report-Only'); config()->set('security.csp.report_only', true); $policy = $this->get('https://presseecho.test/de') ->assertHeaderMissing('Content-Security-Policy') ->headers->get('Content-Security-Policy-Report-Only'); expect($policy)->toContain("default-src 'self'") ->toContain("object-src 'none'") ->toContain("frame-ancestors 'self'") ->toContain('report-uri /csp-report'); $this->getJson('/api/v1/categories')->assertHeaderMissing('Content-Security-Policy-Report-Only'); }); test('csp reports are logged once per hour without query strings or tokens', function () { /** @var TestCase $this */ config()->set('security.csp.report_only', true); $logged = []; Log::shouldReceive('channel')->with('csp')->andReturnSelf(); Log::shouldReceive('warning')->andReturnUsing(function (string $message, array $context) use (&$logged) { $logged[] = $context; }); $report = json_encode(['csp-report' => [ 'document-uri' => 'https://pressekonto.test/magic-login/'.str_repeat('a1B2', 10).'?signature=geheim', 'effective-directive' => 'script-src-elem', 'blocked-uri' => 'https://evil.example/x.js?k=1', 'line-number' => 3, ]]); foreach (range(1, 2) as $i) { $this->call('POST', '/csp-report', [], [], [], ['CONTENT_TYPE' => 'application/csp-report'], $report)->assertNoContent(); } expect($logged)->toHaveCount(1) ->and($logged[0]['document'])->toBe('https://pressekonto.test/magic-login/[Filtered]') ->and($logged[0]['blocked'])->toBe('https://evil.example/x.js') ->and($logged[0]['directive'])->toBe('script-src-elem'); $this->call('POST', '/csp-report', [], [], [], ['CONTENT_TYPE' => 'application/csp-report'], 'kein json')->assertNoContent(); expect($logged)->toHaveCount(1); });