presseportale/tests/Feature/Api/V1/PressReleaseApiTest.php
Kevin Adametz 05cf53e17d 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>
2026-09-18 12:03:04 +00:00

246 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('api user can create list update and delete own press releases', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$createResponse = $this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Neue API Pressemitteilung',
'text' => 'Inhalt der Pressemitteilung',
'status' => 'draft',
]);
$createResponse
->assertCreated()
->assertJsonPath('data.title', 'Neue API Pressemitteilung')
->assertJsonPath('data.portal', 'presseecho');
$pressReleaseId = PressRelease::withoutGlobalScopes()
->where('title', 'Neue API Pressemitteilung')
->value('id');
$this->getJson('/api/v1/press-releases')
->assertOk()
->assertJsonPath('data.0.id', $pressReleaseId);
$this->patchJson("/api/v1/press-releases/{$pressReleaseId}", [
'title' => 'Aktualisierte API Pressemitteilung',
])
->assertOk()
->assertJsonPath('data.title', 'Aktualisierte API Pressemitteilung');
$this->deleteJson("/api/v1/press-releases/{$pressReleaseId}")
->assertNoContent();
expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressReleaseId)?->trashed())->toBeTrue();
});
test('api submitted html text is sanitized before persistence', function () {
/**
* Regression zu Befund M6 (Audit 14.07.2026): Über die API eingereichtes
* HTML muss – wie im Web-Formular – vor der Speicherung durch den Sanitizer.
*/
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'XSS Versuch',
'text' => '<p>Sauber</p><script>alert(document.cookie)</script><a href="javascript:alert(1)">x</a>',
'status' => 'draft',
])->assertCreated();
$stored = PressRelease::withoutGlobalScopes()->where('title', 'XSS Versuch')->firstOrFail();
expect($stored->text)
->not->toContain('<script')
->not->toContain('javascript:')
->toContain('<p>Sauber</p>');
});
test('api write ability is required to create press releases', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Nicht erlaubt',
'text' => 'Inhalt',
])->assertForbidden();
});
test('api user cannot access another users press release', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $owner->id]);
Sanctum::actingAs($otherUser, ['press-releases:read']);
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertForbidden();
});
test('a soft deleted press release disappears from the api (Audit F04)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent();
$this->getJson('/api/v1/press-releases')->assertOk()->assertJsonMissing(['title' => 'Bald geloescht']);
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")->assertForbidden();
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", ['title' => 'Wiederbelebt'])->assertForbidden();
expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressRelease->id)?->title)->toBe('Bald geloescht');
});
test('a soft deleted company is neither listed nor usable for new press releases (Audit F04)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create(['name' => 'Geloeschte Firma']);
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$company->delete();
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
$this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']);
$this->getJson("/api/v1/companies/{$company->id}")->assertForbidden();
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Fuer eine geloeschte Firma',
'text' => 'Inhalt',
])->assertForbidden();
});
test('api list parameters are validated instead of passed through (Audit F11)', function (array $query) {
/** @var TestCase $this */
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read', 'companies:read']);
$this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable();
$this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1')
->assertUnprocessable();
})->with([
'negative per_page' => [['per_page' => -1]],
'zero per_page' => [['per_page' => 0]],
'per_page above the cap' => [['per_page' => 101]],
'textual per_page' => [['per_page' => 'alle']],
'status as array' => [['status' => ['draft'], 'per_page' => -1]],
'unknown status' => [['status' => 'gibt-es-nicht', 'per_page' => -1]],
]);
test('api list accepts the documented parameter range and always limits the query (Audit F11)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read']);
$this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1);
$this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data');
});
test('api press release input rejects unsupported languages and inactive or foreign categories (Audit F11)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]);
$foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$payload = fn (array $overrides): array => [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Vertragspruefung',
'text' => 'Inhalt',
...$overrides,
];
$this->postJson('/api/v1/press-releases', $payload(['language' => 'zz']))->assertUnprocessable()->assertJsonValidationErrors(['language']);
$this->postJson('/api/v1/press-releases', $payload(['category_id' => $inactive->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']);
$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');
});