security/api/betrieb: Fortify-2FA-Nebenwege gesperrt, Policy nur für aktive Super-Admins, Nachprüfungen aus dem Umsetzungsaudit (Block 7, U08–U10, U13, 7.1, 7.2, 7.5)

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 12:03:04 +00:00
parent c46dfd3b83
commit 05cf53e17d
18 changed files with 447 additions and 26 deletions

View file

@ -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,7 +142,45 @@ 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 {
try {
$this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases);
} catch (RuntimeException $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
$watermark = $upperBound;
}
$this->info("Aufrufe verbucht: {$totalEvents} auf {$totalReleases} Pressemitteilungen (Stand Event-ID {$watermark}).");
return self::SUCCESS;
}
/**
* Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer.
*
* @param Collection<int|string, int|string> $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)
@ -134,12 +193,5 @@ class AggregatePressReleaseViews extends Command
AppSetting::setValue(self::WatermarkKey, $upperBound);
});
$watermark = $upperBound;
}
$this->info("Aufrufe verbucht: {$totalEvents} auf {$totalReleases} Pressemitteilungen (Stand Event-ID {$watermark}).");
return self::SUCCESS;
}
}

View file

@ -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']);

View file

@ -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([

View file

@ -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.'));
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Sperrt die Fortify-Routen zur 2FA-Verwaltung, die das Projekt nicht nutzt
* (Audit U09, Entscheidung E3/E8).
*
* Die Sicherheitsseite (customer/security) ruft die Fortify-Actions direkt
* auf und verlangt vor Deaktivierung und Recovery-Codes einen TOTP- oder
* Recovery-Code. Fortify registriert daneben eigene Routen für dieselben
* Aktionen, die nur `password.confirm` verlangen – eine bis zu drei Stunden
* alte Passwortbestätigung hätte den zweiten Faktor ersetzt. Fortify kennt
* kein Abschalten einzelner Routen (nur `ignoreRoutes()` für alle), deshalb
* hängt diese Middleware über `fortify.middleware` an allen Fortify-Routen
* und beantwortet die ungenutzten mit 404. Challenge und Passwortwechsel
* bleiben erreichbar.
*/
class BlockUnusedFortifyRoutes
{
/**
* @var list<string>
*/
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);
}
}

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\BlockUnusedFortifyRoutes;
use Laravel\Fortify\Features;
return [
@ -99,9 +100,13 @@ return [
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
| BlockUnusedFortifyRoutes (Audit U09): die 2FA-Verwaltungsrouten von
| Fortify laufen nur mit Passwortbestätigung; das Projekt nutzt sie nicht
| und antwortet mit 404 – die Sicherheitsseite verlangt den zweiten Faktor.
|
*/
'middleware' => ['web'],
'middleware' => ['web', BlockUnusedFortifyRoutes::class],
/*
|--------------------------------------------------------------------------

View file

@ -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

View file

@ -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();

View file

@ -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');
});

View file

@ -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');
});

View file

@ -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();
});

View file

@ -0,0 +1,55 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* Fortifys eigene 2FA-Verwaltungsrouten verlangen nur `password.confirm`.
* Das Projekt verlangt den zweiten Faktor (E3) und beantwortet diese
* Nebenwege deshalb mit 404 (Audit U09).
*/
function twoFactorUser(): User
{
return User::factory()->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();
});

View file

@ -1,5 +1,7 @@
<?php
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Gate;
@ -80,3 +82,21 @@ test('a deactivated super admin is locked out of both panels despite the gate sh
$this->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();
});

View file

@ -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);
});

View file

@ -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);
});