From 05cf53e17dbcd9db411220eb014f1ed5764b6eae Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 12:03:04 +0000 Subject: [PATCH] =?UTF-8?q?security/api/betrieb:=20Fortify-2FA-Nebenwege?= =?UTF-8?q?=20gesperrt,=20Policy=20nur=20f=C3=BCr=20aktive=20Super-Admins,?= =?UTF-8?q?=20Nachpr=C3=BCfungen=20aus=20dem=20Umsetzungsaudit=20(Block=20?= =?UTF-8?q?7,=20U08=E2=80=93U10,=20U13,=207.1,=207.2,=207.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fortifys sieben 2FA-Verwaltungsrouten antworten mit 404 (Middleware über fortify.middleware); die Sicherheitsseite verlangt weiter den zweiten Faktor (U09) - PressReleasePolicy/CompanyPolicy::before() bejahen nur aktive Super-Admins (7.2) - API-Update prüft bei Portalwechsel die bestehende Kategorie gegen das neue Portal (U10) - Aggregation: Zeitbudget 480 s unter dem Lock von 600 s, Marke nur unter Zeilensperre vom erwarteten Stand aus, sonst Fehler-Exit (U13) - Newsletter-Bereinigungsmigration behält je Paar die sent-Zeile (U08) - E-Mail-Wechsel stößt den Rechte-Sync sofort an; company-memberships:audit --classify zeigt vorher die Kandidaten und begrenzt mit --before (7.1) - Detailseite wählt bei Slug-Kollision wie der Melde-Resolver (7.5) Co-Authored-By: Claude Fable 5.1 --- .../Commands/AggregatePressReleaseViews.php | 80 +++++++++++++++---- .../Commands/AuditCompanyMemberships.php | 41 ++++++++-- .../Api/V1/PressReleaseController.php | 5 ++ .../Auth/ConfirmEmailChangeController.php | 8 +- .../Middleware/BlockUnusedFortifyRoutes.php | 46 +++++++++++ app/Policies/CompanyPolicy.php | 6 +- app/Policies/PressReleasePolicy.php | 6 +- app/Services/Web/ReleaseDetailPageData.php | 4 + config/fortify.php | 7 +- ...o_newsletter_campaign_recipients_table.php | 7 +- .../NewsletterCampaignResilienceTest.php | 29 +++++++ tests/Feature/Api/V1/PressReleaseApiTest.php | 40 ++++++++++ .../Auth/CompanyMembershipAuditTest.php | 25 ++++++ tests/Feature/Auth/ContactAccessTest.php | 25 ++++++ .../Auth/FortifyTwoFactorRoutesTest.php | 55 +++++++++++++ tests/Feature/Auth/UserAccessTest.php | 20 +++++ .../Feature/Web/PressReleaseTrackingTest.php | 44 ++++++++++ tests/Feature/Web/ReleaseDetailTest.php | 25 ++++++ 18 files changed, 447 insertions(+), 26 deletions(-) create mode 100644 app/Http/Middleware/BlockUnusedFortifyRoutes.php create mode 100644 tests/Feature/Auth/FortifyTwoFactorRoutesTest.php diff --git a/app/Console/Commands/AggregatePressReleaseViews.php b/app/Console/Commands/AggregatePressReleaseViews.php index ba2b3a6f..34abb550 100644 --- a/app/Console/Commands/AggregatePressReleaseViews.php +++ b/app/Console/Commands/AggregatePressReleaseViews.php @@ -8,8 +8,10 @@ use App\Models\PressRelease; use App\Models\PressReleaseEvent; use App\Scopes\PortalScope; use Illuminate\Console\Command; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use RuntimeException; /** * Schreibt die gezählten Aufrufe aus dem Ereignis-Log in den Gesamtzähler @@ -32,7 +34,9 @@ use Illuminate\Support\Facades\DB; */ class AggregatePressReleaseViews extends Command { - protected $signature = 'press-releases:aggregate-views {--chunk=5000 : Ereignisse je Durchgang}'; + protected $signature = 'press-releases:aggregate-views + {--chunk=5000 : Ereignisse je Durchgang} + {--budget=480 : Zeitbudget in Sekunden; der Rest wartet auf den nächsten Lauf}'; protected $description = 'Überträgt gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits.'; @@ -48,9 +52,18 @@ class AggregatePressReleaseViews extends Command */ public const LockKey = 'press-releases:aggregate-views'; + /** + * Der Lock verfällt nach dieser Zeit. Das Zeitbudget (--budget) muss + * darunter liegen, sonst kann ein zweiter Lauf den Lock übernehmen, + * während der erste noch verbucht (Audit U13). Als letzte Schranke wird + * die Marke nur unter Zeilensperre und nur vom erwarteten Stand aus + * fortgeschrieben – ein fremder Stand bricht den Durchgang ab. + */ + public const LockSeconds = 600; + public function handle(): int { - $lock = Cache::lock(self::LockKey, 600); + $lock = Cache::lock(self::LockKey, self::LockSeconds); if (! $lock->get()) { $this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.'); @@ -68,6 +81,8 @@ class AggregatePressReleaseViews extends Command private function aggregate(): int { $chunk = max(100, (int) $this->option('chunk')); + $budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget'))); + $startedAt = microtime(true); $watermark = AppSetting::getValue(self::WatermarkKey); // Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu @@ -91,6 +106,12 @@ class AggregatePressReleaseViews extends Command $totalReleases = 0; while (true) { + if (microtime(true) - $startedAt > $budget) { + $this->warn(sprintf('Zeitbudget von %d s erreicht – der Rest folgt im nächsten Lauf.', $budget)); + + break; + } + // Obergrenze des Durchgangs zuerst festlegen, damit gleichzeitig // eintreffende Aufrufe die Summe nicht gegen die Marke verschieben. // Bewusst über die IDs statt über max(): eine Aggregatfunktion @@ -121,19 +142,14 @@ class AggregatePressReleaseViews extends Command // Zähler schon erhöht, die Marke steht aber noch auf dem alten // Stand — der nächste Lauf verbuchte genau diese Aufrufe ein // zweites Mal. - DB::transaction(function () use ($counts, $upperBound, &$totalEvents, &$totalReleases): void { - foreach ($counts as $pressReleaseId => $views) { - PressRelease::query() - ->withoutGlobalScope(PortalScope::class) - ->whereKey((int) $pressReleaseId) - ->increment('hits', (int) $views); + try { + $this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases); + } catch (RuntimeException $exception) { + report($exception); + $this->error($exception->getMessage()); - $totalReleases++; - $totalEvents += (int) $views; - } - - AppSetting::setValue(self::WatermarkKey, $upperBound); - }); + return self::FAILURE; + } $watermark = $upperBound; } @@ -142,4 +158,40 @@ class AggregatePressReleaseViews extends Command return self::SUCCESS; } + + /** + * Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer. + * + * @param Collection $counts + */ + private function bookChunk(Collection $counts, int $watermark, int $upperBound, int &$totalEvents, int &$totalReleases): void + { + DB::transaction(function () use ($counts, $watermark, $upperBound, &$totalEvents, &$totalReleases): void { + // Marke unter Zeilensperre gegen den erwarteten Stand prüfen: + // hat ein anderer Lauf sie inzwischen verschoben (Lock + // verfallen), würde dieser Durchgang dieselben Ereignisse ein + // zweites Mal verbuchen (Audit U13). + $current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first(); + + if ($current === null || (int) $current->value !== $watermark) { + throw new RuntimeException(sprintf( + 'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.', + $current === null ? 'null' : (string) $current->value, + $watermark, + )); + } + + foreach ($counts as $pressReleaseId => $views) { + PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->whereKey((int) $pressReleaseId) + ->increment('hits', (int) $views); + + $totalReleases++; + $totalEvents += (int) $views; + } + + AppSetting::setValue(self::WatermarkKey, $upperBound); + }); + } } diff --git a/app/Console/Commands/AuditCompanyMemberships.php b/app/Console/Commands/AuditCompanyMemberships.php index 8433118b..02ddc613 100644 --- a/app/Console/Commands/AuditCompanyMemberships.php +++ b/app/Console/Commands/AuditCompanyMemberships.php @@ -20,12 +20,19 @@ use Illuminate\Support\Facades\Storage; * der Login-Sync sie zurücknehmen kann, sobald die Grundlage wegfällt. Nicht * erklärbare Zeilen bleiben `manual` und damit geschützt – die Entscheidung * je Zeile trifft ein Mensch anhand der Liste. + * + * Die Klassifizierung ist eine Heuristik (Audit 7.1): eine bewusst manuell + * vergebene Verantwortlichkeit mit zufällig passender Adresse wird ebenfalls + * als auto markiert. Deshalb zeigt --classify vorher die betroffenen Zeilen, + * und --before begrenzt den Lauf auf Zuordnungen aus der Zeit vor dem Deploy + * von `granted_via` – alles Jüngere trägt seine Herkunft bereits. */ class AuditCompanyMemberships extends Command { protected $signature = 'company-memberships:audit {--classify : Erklärbare responsible-Zuordnungen als automatisch (auto) markieren} - {--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}'; + {--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle} + {--before= : --classify nur für Zuordnungen, die vor diesem Zeitpunkt entstanden sind (Deploy von granted_via); danach ist die Herkunft bereits gespeichert}'; protected $description = 'Listet responsible-Firmenzuordnungen ohne aktuelle E-Mail-Grundlage und markiert erklärbare als automatisch vergeben.'; @@ -73,13 +80,37 @@ class AuditCompanyMemberships extends Command return self::SUCCESS; } + $before = (string) $this->option('before'); + $candidates = $this->responsibleRows() + ->filter(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id])) + ->where('granted_via', '!=', 'auto') + ->filter(fn (object $row): bool => $before === '' || (string) $row->created_at < $before) + ->values(); + + $this->line(sprintf( + 'Umzustufen auf auto: %d Zuordnungen%s (Stichprobe, max. 20):', + $candidates->count(), + $before !== '' ? sprintf(' vor %s', $before) : '', + )); + $this->table( + ['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'], + $candidates->take(20)->map(fn (object $row): array => [ + $row->user_id, + $row->email, + $row->company_id, + mb_strimwidth((string) $row->company_name, 0, 40, '…'), + $row->granted_via, + substr((string) $row->created_at, 0, 16), + ])->all(), + ); + $marked = 0; - foreach (collect($explained)->chunk(500) as $chunk) { - foreach ($chunk as $pair) { + foreach ($candidates->chunk(500) as $chunk) { + foreach ($chunk as $row) { $marked += DB::table('company_user') - ->where('company_id', $pair['company_id']) - ->where('user_id', $pair['user_id']) + ->where('company_id', $row->company_id) + ->where('user_id', $row->user_id) ->where('role', 'responsible') ->where('granted_via', '!=', 'auto') ->update(['granted_via' => 'auto']); diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 5277d511..f51f37a9 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -135,8 +135,13 @@ class PressReleaseController extends Controller ?? $pressRelease->portal ?? $company->portal)->value; + // Der resultierende Zustand muss passen (Audit U10): eine neue + // Kategorie gegen das Ziel-Portal, bei reinem Portalwechsel die + // bestehende Kategorie gegen das neue Portal. if (isset($validated['category_id'])) { $this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request); + } elseif ($portal !== $pressRelease->portal->value) { + $this->assertCategoryFitsPortal((int) $pressRelease->category_id, $portal, $request); } $pressRelease->fill([ diff --git a/app/Http/Controllers/Auth/ConfirmEmailChangeController.php b/app/Http/Controllers/Auth/ConfirmEmailChangeController.php index 1a4ca0ec..5b7b693f 100644 --- a/app/Http/Controllers/Auth/ConfirmEmailChangeController.php +++ b/app/Http/Controllers/Auth/ConfirmEmailChangeController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\Auth\ContactAccessService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -14,7 +15,7 @@ class ConfirmEmailChangeController extends Controller * signierten Link aus der Mail an die NEUE Adresse geklickt hat. * Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern). */ - public function __invoke(Request $request, User $user, string $hash): RedirectResponse + public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse { abort_unless($request->user()->is($user), 403); @@ -39,6 +40,11 @@ class ConfirmEmailChangeController extends Controller 'pending_email' => null, ])->save(); + // Die Firmenrechte hängen an der Adresse (Audit 7.1): automatische + // Rechte der alten Adresse sofort zurücknehmen, passende der neuen + // vergeben – nicht erst beim nächsten Login. + $contactAccess->syncMembershipsForEmail($user); + return redirect()->route('me.security') ->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.')); } diff --git a/app/Http/Middleware/BlockUnusedFortifyRoutes.php b/app/Http/Middleware/BlockUnusedFortifyRoutes.php new file mode 100644 index 00000000..b2f38128 --- /dev/null +++ b/app/Http/Middleware/BlockUnusedFortifyRoutes.php @@ -0,0 +1,46 @@ + + */ + public const BLOCKED_ROUTES = [ + 'two-factor.enable', + 'two-factor.confirm', + 'two-factor.disable', + 'two-factor.qr-code', + 'two-factor.secret-key', + 'two-factor.recovery-codes', + 'two-factor.regenerate-recovery-codes', + ]; + + public function handle(Request $request, Closure $next): Response + { + if (in_array($request->route()?->getName(), self::BLOCKED_ROUTES, true)) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Policies/CompanyPolicy.php b/app/Policies/CompanyPolicy.php index 9efb60f1..017917c1 100644 --- a/app/Policies/CompanyPolicy.php +++ b/app/Policies/CompanyPolicy.php @@ -8,9 +8,13 @@ use App\Scopes\PortalScope; class CompanyPolicy { + /** + * Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes + * Konto fällt auf die regulären Prüfungen zurück, die es ablehnen. + */ public function before(User $user): ?bool { - return $user->is_super_admin ? true : null; + return ($user->is_super_admin && $user->is_active) ? true : null; } public function viewAny(User $user): bool diff --git a/app/Policies/PressReleasePolicy.php b/app/Policies/PressReleasePolicy.php index 9c0d2ac4..a0991e7b 100644 --- a/app/Policies/PressReleasePolicy.php +++ b/app/Policies/PressReleasePolicy.php @@ -8,9 +8,13 @@ use App\Models\User; class PressReleasePolicy { + /** + * Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes + * Konto fällt auf die regulären Prüfungen zurück, die es ablehnen. + */ public function before(User $user): ?bool { - return $user->is_super_admin ? true : null; + return ($user->is_super_admin && $user->is_active) ? true : null; } public function viewAny(User $user): bool diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 77563d39..87f2d187 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -43,6 +43,10 @@ class ReleaseDetailPageData 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'), 'contacts', ]) + // Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie + // ReportedUrlResolver – die Erstquelle gewinnt (Audit 7.5). + ->orderByRaw('canonical_press_release_id is null desc') + ->orderBy('id') ->first(); if (! $release) { diff --git a/config/fortify.php b/config/fortify.php index ae63c379..c80f6118 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -1,5 +1,6 @@ ['web'], + 'middleware' => ['web', BlockUnusedFortifyRoutes::class], /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php b/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php index 0d2b0d9f..63746683 100644 --- a/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php +++ b/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php @@ -11,8 +11,9 @@ use Illuminate\Support\Facades\Schema; * zweiten Seite Empfänger doppelt eingefügt. Der Unique-Index macht den * Start idempotent (insertOrIgnore) und fängt jede weitere Doppelung ab. * - * Bestehende Doppelungen werden vorher bereinigt — es bleibt jeweils die - * älteste Zeile. Zeilen ohne User (gelöschte Accounts) sind nicht betroffen; + * Bestehende Doppelungen werden vorher bereinigt — es bleibt je Paar eine + * Zeile mit Status `sent` (der Zustellnachweis, Audit U08), sonst die + * älteste. Zeilen ohne User (gelöschte Accounts) sind nicht betroffen; * NULL zählt im Unique-Index nicht als Doppelung. * * Kleine Tabelle ohne FULLTEXT: der ALTER läuft sofort. @@ -27,7 +28,7 @@ return new class extends Migration WHERE user_id IS NOT NULL AND id NOT IN ( SELECT keep_id FROM ( - SELECT MIN(id) AS keep_id + SELECT COALESCE(MIN(CASE WHEN status = 'sent' THEN id END), MIN(id)) AS keep_id FROM newsletter_campaign_recipients WHERE user_id IS NOT NULL GROUP BY newsletter_campaign_id, user_id diff --git a/tests/Feature/Admin/NewsletterCampaignResilienceTest.php b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php index ab19dbd9..2a05f5f4 100644 --- a/tests/Feature/Admin/NewsletterCampaignResilienceTest.php +++ b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php @@ -104,6 +104,35 @@ test('the unique index migration removes existing duplicates and keeps the oldes ->and($campaign->recipients()->whereNull('user_id')->count())->toBe(2); }); +test('the unique index migration keeps the delivered row of a duplicated recipient (Audit U08)', function () { + /** @var TestCase $this */ + $migration = 'database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php'; + + $this->artisan('migrate:rollback', ['--path' => $migration])->assertSuccessful(); + + $campaign = NewsletterCampaign::factory()->create(); + + // Pending → Sent: die ältere Zeile ist offen, die jüngere trägt den Nachweis. + $pendingThenSent = User::factory()->create(); + $campaign->recipients()->create(['user_id' => $pendingThenSent->id, 'email' => $pendingThenSent->email]); + $keptA = $campaign->recipients()->create(['user_id' => $pendingThenSent->id, 'email' => $pendingThenSent->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]); + + // Failed → Sent + $failedThenSent = User::factory()->create(); + $campaign->recipients()->create(['user_id' => $failedThenSent->id, 'email' => $failedThenSent->email, 'status' => NewsletterRecipientStatus::Failed->value]); + $keptB = $campaign->recipients()->create(['user_id' => $failedThenSent->id, 'email' => $failedThenSent->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]); + + // Sent → Sent: der ältere Nachweis bleibt. + $sentTwice = User::factory()->create(); + $keptC = $campaign->recipients()->create(['user_id' => $sentTwice->id, 'email' => $sentTwice->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]); + $campaign->recipients()->create(['user_id' => $sentTwice->id, 'email' => $sentTwice->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]); + + $this->artisan('migrate', ['--path' => $migration])->assertSuccessful(); + + expect($campaign->recipients()->orderBy('id')->pluck('id')->all())->toBe([$keptA->id, $keptB->id, $keptC->id]) + ->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Sent->value)->count())->toBe(3); +}); + test('a campaign cannot hold the same user twice', function () { $user = User::factory()->create(); $campaign = NewsletterCampaign::factory()->create(); diff --git a/tests/Feature/Api/V1/PressReleaseApiTest.php b/tests/Feature/Api/V1/PressReleaseApiTest.php index 8a5ce461..78e421a0 100644 --- a/tests/Feature/Api/V1/PressReleaseApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseApiTest.php @@ -204,3 +204,43 @@ test('api press release input rejects unsupported languages and inactive or fore $this->postJson('/api/v1/press-releases', $payload(['category_id' => $foreign->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']); $this->postJson('/api/v1/press-releases', $payload(['language' => 'en']))->assertCreated(); }); + +test('api portal change re-validates the existing category against the new portal (Audit U10)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + $presseechoOnly = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => true]); + $businessOnly = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); + $shared = Category::factory()->withTranslations()->create(['portal' => Portal::Both, 'is_active' => true]); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + + $id = $this->postJson('/api/v1/press-releases', [ + 'company_id' => $company->id, + 'category_id' => $presseechoOnly->id, + 'language' => 'de', + 'title' => 'Portalwechsel', + 'text' => 'Inhalt', + ])->assertCreated()->json('data.id'); + + // Reiner Portalwechsel mit portalfremder Kategorie: abgelehnt. + $this->patchJson("/api/v1/press-releases/{$id}", ['portal' => 'businessportal24']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['category_id']); + expect(PressRelease::withoutGlobalScopes()->find($id)->portal)->toBe(Portal::Presseecho); + + // Titel allein bleibt möglich, auch wenn die Kategorie unverändert ist. + $this->patchJson("/api/v1/press-releases/{$id}", ['title' => 'Nur Titel'])->assertOk(); + + // Wechsel zusammen mit passender Kategorie: erlaubt. + $this->patchJson("/api/v1/press-releases/{$id}", ['portal' => 'businessportal24', 'category_id' => $businessOnly->id]) + ->assertOk() + ->assertJsonPath('data.portal', 'businessportal24'); + + // Zurück mit einer gemeinsamen Kategorie: reiner Portalwechsel erlaubt. + $this->patchJson("/api/v1/press-releases/{$id}", ['category_id' => $shared->id])->assertOk(); + $this->patchJson("/api/v1/press-releases/{$id}", ['portal' => 'presseecho']) + ->assertOk() + ->assertJsonPath('data.portal', 'presseecho'); +}); diff --git a/tests/Feature/Auth/CompanyMembershipAuditTest.php b/tests/Feature/Auth/CompanyMembershipAuditTest.php index a4d07ccf..d0857cd7 100644 --- a/tests/Feature/Auth/CompanyMembershipAuditTest.php +++ b/tests/Feature/Auth/CompanyMembershipAuditTest.php @@ -51,3 +51,28 @@ test('the audit lists unexplained responsible links and classifies explained one expect(collect(Storage::disk('local')->files())->filter(fn (string $f) => str_starts_with($f, 'company-memberships-unexplained-')))->toHaveCount(1); }); + +test('classify previews its candidates and skips links created after the given deploy time (Audit 7.1)', function () { + /** @var TestCase $this */ + Storage::fake('local'); + + $legacy = User::factory()->create(['email' => 'alt@example.test']); + $recent = User::factory()->create(['email' => 'neu@example.test']); + $companyOld = Company::factory()->presseecho()->create(['email' => 'alt@example.test', 'name' => 'Altbestand AG']); + $companyNew = Company::factory()->presseecho()->create(['email' => 'neu@example.test', 'name' => 'Frisch GmbH']); + + $legacy->companies()->attach($companyOld->id, ['role' => 'responsible', 'created_at' => '2026-01-10 08:00:00', 'updated_at' => '2026-01-10 08:00:00']); + // Nach dem Deploy manuell vergeben: trägt ihre Herkunft bereits, darf nicht umgestuft werden. + $recent->companies()->attach($companyNew->id, ['role' => 'responsible', 'granted_via' => 'manual', 'created_at' => '2026-09-20 09:00:00', 'updated_at' => '2026-09-20 09:00:00']); + + $this->artisan('company-memberships:audit', ['--classify' => true, '--since' => '2000-01-01', '--before' => '2026-09-19 00:00:00']) + ->expectsOutputToContain('Umzustufen auf auto: 1 Zuordnungen vor 2026-09-19 00:00:00') + ->expectsOutputToContain('Altbestand AG') + ->expectsOutputToContain('1 Zuordnungen als automatisch (auto) markiert.') + ->assertSuccessful(); + + $via = fn (int $userId, int $companyId): string => DB::table('company_user')->where('user_id', $userId)->where('company_id', $companyId)->value('granted_via'); + + expect($via($legacy->id, $companyOld->id))->toBe('auto') + ->and($via($recent->id, $companyNew->id))->toBe('manual'); +}); diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php index bc07f31d..2620ea7d 100644 --- a/tests/Feature/Auth/ContactAccessTest.php +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Auth\ContactAccessService; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Livewire\Volt\Volt; use Tests\TestCase; @@ -403,3 +404,27 @@ test('a company email stored with capitals still links the lowercase account on expect(User::query()->findOrFail($user->id)->accessibleCompanyIds())->toContain($company->id); }); + +test('a confirmed email change re-syncs company rights immediately (Audit 7.1)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'alt@example.test', 'is_active' => true]); + $user->assignRole('customer'); + + $oldCompany = Company::factory()->presseecho()->create(['email' => 'alt@example.test']); + $newCompany = Company::factory()->presseecho()->create(['email' => 'neu@example.test']); + + Volt::test('auth.login')->set('email', 'alt@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + expect($user->fresh()->can('update', $oldCompany))->toBeTrue(); + + $user->forceFill(['pending_email' => 'neu@example.test'])->save(); + $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), ['user' => $user->id, 'hash' => sha1('neu@example.test')]); + + $this->actingAs($user)->get($url)->assertRedirect(route('me.security')); + + $fresh = User::query()->findOrFail($user->id); + expect($fresh->email)->toBe('neu@example.test') + ->and($fresh->companies()->withoutGlobalScopes()->whereKey($oldCompany->id)->first()->pivot->role)->toBe('member') + ->and($fresh->companies()->withoutGlobalScopes()->whereKey($newCompany->id)->first()?->pivot->role)->toBe('responsible') + ->and($fresh->can('update', $oldCompany))->toBeFalse() + ->and($fresh->can('update', $newCompany))->toBeTrue(); +}); diff --git a/tests/Feature/Auth/FortifyTwoFactorRoutesTest.php b/tests/Feature/Auth/FortifyTwoFactorRoutesTest.php new file mode 100644 index 00000000..4f85b1f4 --- /dev/null +++ b/tests/Feature/Auth/FortifyTwoFactorRoutesTest.php @@ -0,0 +1,55 @@ +create([ + 'is_active' => true, + 'password' => Hash::make('geheim-1234'), + 'two_factor_secret' => encrypt('ABCDEFGHIJKLMNOP'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code-one', 'code-two'])), + 'two_factor_confirmed_at' => now(), + ]); +} + +test('fortify two-factor management routes are unreachable even with a confirmed password (Audit U09)', function (string $method, string $uri) { + /** @var TestCase $this */ + $user = twoFactorUser(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->{$method}($uri) + ->assertNotFound(); + + expect($user->fresh()->two_factor_secret)->not->toBeNull() + ->and($user->fresh()->two_factor_recovery_codes)->not->toBeNull(); +})->with([ + 'enable' => ['post', '/user/two-factor-authentication'], + 'confirm' => ['post', '/user/confirmed-two-factor-authentication'], + 'disable' => ['delete', '/user/two-factor-authentication'], + 'qr code' => ['get', '/user/two-factor-qr-code'], + 'secret key' => ['get', '/user/two-factor-secret-key'], + 'recovery codes' => ['get', '/user/two-factor-recovery-codes'], + 'regenerate recovery codes' => ['post', '/user/two-factor-recovery-codes'], +]); + +test('the two-factor challenge stays reachable', function () { + /** @var TestCase $this */ + $user = twoFactorUser(); + + $this->withSession(['login.id' => $user->id, 'login.remember' => false]) + ->get('/two-factor-challenge') + ->assertOk(); + + $this->withSession(['login.id' => $user->id, 'login.remember' => false]) + ->post('/two-factor-challenge', ['code' => '000000']) + ->assertRedirect(); +}); diff --git a/tests/Feature/Auth/UserAccessTest.php b/tests/Feature/Auth/UserAccessTest.php index 9e10b716..663160c8 100644 --- a/tests/Feature/Auth/UserAccessTest.php +++ b/tests/Feature/Auth/UserAccessTest.php @@ -1,5 +1,7 @@ actingAs($user)->get(route('dashboard'))->assertForbidden(); $this->actingAs($user)->get(route('me.dashboard'))->assertForbidden(); }); + +test('a deactivated super admin is not whitelisted by the policy shortcuts either (Audit 7.2)', function () { + $user = User::factory()->create([ + 'is_active' => false, + 'is_super_admin' => true, + ]); + $pressRelease = PressRelease::factory()->create(); + $company = Company::factory()->create(); + + expect(Gate::forUser($user)->allows('update', $pressRelease))->toBeFalse() + ->and(Gate::forUser($user)->allows('view', $pressRelease))->toBeFalse() + ->and(Gate::forUser($user)->allows('update', $company))->toBeFalse() + ->and(Gate::forUser($user)->allows('view', $company))->toBeFalse(); + + $user->forceFill(['is_active' => true])->save(); + + expect(Gate::forUser($user->fresh())->allows('update', $pressRelease))->toBeTrue(); +}); diff --git a/tests/Feature/Web/PressReleaseTrackingTest.php b/tests/Feature/Web/PressReleaseTrackingTest.php index 8247ea00..956f0f1d 100644 --- a/tests/Feature/Web/PressReleaseTrackingTest.php +++ b/tests/Feature/Web/PressReleaseTrackingTest.php @@ -13,6 +13,7 @@ use App\Models\PressReleaseEvent; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -265,3 +266,46 @@ test('a second aggregation run is refused while another one holds the lock (Audi $this->artisan('press-releases:aggregate-views')->assertSuccessful(); expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1); }); + +test('the aggregation stops at its time budget and continues on the next run (Audit U13)', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + $this->get($url)->assertSuccessful(); + $this->get($url)->assertSuccessful(); + $this->get($url)->assertSuccessful(); + + // Budget 0: der erste Durchgang läuft gar nicht erst an. + $this->artisan('press-releases:aggregate-views', ['--budget' => 0]) + ->expectsOutputToContain('Zeitbudget') + ->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0); + + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(3); +}); + +test('a run whose watermark was moved by another run books nothing twice (Audit U13)', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + $this->get($url)->assertSuccessful(); + + $latest = (int) PressReleaseEvent::query()->max('id'); + $moved = false; + + // Nachdem der Lauf seinen Durchgang gezählt hat, aber bevor er verbucht, + // schiebt ein „anderer Lauf“ die Marke bereits über dieses Ereignis. + DB::listen(function ($query) use (&$moved, $latest): void { + if (! $moved && str_contains($query->sql, 'count(*) as views')) { + $moved = true; + DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->update(['value' => json_encode($latest)]); + } + }); + + $this->artisan('press-releases:aggregate-views')->assertFailed(); + + expect($moved)->toBeTrue() + ->and(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0) + ->and((int) DB::table('app_settings')->where('key', AggregatePressReleaseViews::WatermarkKey)->value('value'))->toBe($latest); +}); diff --git a/tests/Feature/Web/ReleaseDetailTest.php b/tests/Feature/Web/ReleaseDetailTest.php index f39b5d1d..1bf2abd8 100644 --- a/tests/Feature/Web/ReleaseDetailTest.php +++ b/tests/Feature/Web/ReleaseDetailTest.php @@ -12,6 +12,7 @@ use App\Models\Contact; use App\Models\PressRelease; use App\Models\User; use App\Services\Company\NewsroomClaimService; +use App\Services\Web\ReportedUrlResolver; use Tests\TestCase; /** @@ -508,3 +509,27 @@ test('a deleted company contact is not shown as fallback press contact (Audit F0 ->assertDontSeeText('Ehemalige Sprecherin') ->assertDontSee('ehemalig@muster-energie.de'); }); + +test('the detail page and the reported-url resolver pick the same release when slugs collide (Audit 7.5)', function () { + /** @var TestCase $this */ + [$category, $company] = detailCategoryAndCompany(Portal::Presseecho); + + // Die Zweitfassung entsteht mit kleinerer ID, zeigt aber per canonical auf die Erstquelle. + $copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ + 'title' => 'Zweitfassung auf presseecho', + 'slug' => 'doppelt-veroeffentlicht', + 'published_at' => now()->subDay(), + ]); + $original = PressRelease::factory()->published()->forPortal(Portal::Both)->for($category)->for($company)->create([ + 'title' => 'Erstquelle auf beiden Portalen', + 'slug' => 'doppelt-veroeffentlicht', + 'published_at' => now()->subDays(2), + ]); + $copy->forceFill(['canonical_press_release_id' => $original->id])->save(); + + $url = 'https://presseecho.test/de/press-release/doppelt-veroeffentlicht'; + + $this->get($url)->assertSuccessful()->assertSeeText('Erstquelle auf beiden Portalen'); + + expect(app(ReportedUrlResolver::class)->resolve($url)?->id)->toBe($original->id); +});