create(); $company = Company::factory()->presseecho()->create(); $category = Category::factory()->withTranslations()->create(); $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(); $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' => '

Sauber

x', 'status' => 'draft', ])->assertCreated(); $stored = PressRelease::withoutGlobalScopes()->where('title', 'XSS Versuch')->firstOrFail(); expect($stored->text) ->not->toContain('not->toContain('javascript:') ->toContain('

Sauber

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