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:
parent
fb03fb166d
commit
235d3928ab
18 changed files with 447 additions and 26 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
55
tests/Feature/Auth/FortifyTwoFactorRoutesTest.php
Normal file
55
tests/Feature/Auth/FortifyTwoFactorRoutesTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue