- 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>
246 lines
11 KiB
PHP
246 lines
11 KiB
PHP
<?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');
|
||
});
|