audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)

Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 14:26:49 +00:00
parent 34fa342c7f
commit 2a2215b3c3
66 changed files with 3092 additions and 368 deletions

View file

@ -546,3 +546,142 @@ test('a green re-check without routing leaves the status untouched (Audit U03)',
Mail::assertNothingQueued();
});
test('a classification is only stored together with its audit — a failed audit write rolls the result back (Audit N03)', function () {
/** @var TestCase $this */
Queue::fake();
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', []);
// Fällige Termin-PM: genau die Kandidatin, die der Scheduler zieht.
$pressRelease = PressRelease::factory()->create([
'title' => 'Sauber',
'text' => 'Inhalt',
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => now()->subMinute(),
]);
// Zwischen Ergebnis-UPDATE und Audit-INSERT darf kein Zustand sichtbar
// werden, in dem eine grüne PM ohne Nachweis dasteht — der Scheduler
// behandelte sie sonst wie auditlosen Altbestand.
$auditAttempted = false;
DB::beforeExecuting(function (string $query) use (&$auditAttempted): void {
if (str_starts_with($query, 'insert') && str_contains($query, 'ki_audits')) {
$auditAttempted = true;
throw new RuntimeException('Audit-Tabelle nicht erreichbar');
}
});
expect(fn () => (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle(
app(ClassificationManager::class),
app(PressReleaseService::class),
))->toThrow(RuntimeException::class);
expect($auditAttempted)->toBeTrue();
$fresh = $pressRelease->fresh();
expect($fresh->classification)->toBeNull()
->and($fresh->classified_at)->toBeNull()
->and($fresh->status)->toBe(PressReleaseStatus::Review)
->and(KiAudit::where('press_release_id', $pressRelease->id)->count())->toBe(0);
$this->artisan('press-releases:publish-scheduled')->assertSuccessful();
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('a red re-check whose content was corrected before the reject lock does not reject and re-checks the current version (Audit N04)', function () {
Mail::fake();
Queue::fake();
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', ['gesperrt']);
$author = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'classified_at' => now()->subHour(),
'scheduled_at' => now()->addWeek(),
'title' => 'Sauber',
'text' => 'Dieser Inhalt ist jetzt gesperrt.',
]);
$redHash = $pressRelease->content_hash;
$correctedHash = str_repeat('e', 64);
// Der Editor korrigiert exakt zwischen dem Status-Refresh des Jobs (erster
// SELECT nach dem Audit) und der Zeilensperre in reject().
$auditWritten = false;
$raced = false;
DB::listen(function ($query) use (&$auditWritten, &$raced, $pressRelease, $correctedHash): void {
if (str_starts_with($query->sql, 'insert') && str_contains($query->sql, 'ki_audits')) {
$auditWritten = true;
return;
}
if ($auditWritten && ! $raced && str_starts_with($query->sql, 'select') && str_contains($query->sql, 'press_releases')) {
$raced = true;
DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]);
}
});
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle(
app(ClassificationManager::class),
app(PressReleaseService::class),
);
expect($raced)->toBeTrue();
$fresh = $pressRelease->fresh();
expect($fresh->status)->toBe(PressReleaseStatus::Review)
->and($fresh->content_hash)->toBe($correctedHash);
Mail::assertNothingQueued();
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id
&& $job->route === true
&& $job->contentHash === $correctedHash);
});
test('a red re-check whose content changed right after the result was stored skips the rejection (Audit N04)', function () {
Mail::fake();
Queue::fake();
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', ['gesperrt']);
$pressRelease = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'classified_at' => now()->subHour(),
'scheduled_at' => now()->addWeek(),
'title' => 'Sauber',
'text' => 'Dieser Inhalt ist jetzt gesperrt.',
]);
$redHash = $pressRelease->content_hash;
$correctedHash = str_repeat('f', 64);
// Korrektur nach dem bedingten UPDATE, aber vor dem Audit — das Rot gilt
// für die alte Fassung; die Bearbeitung selbst plant den neuen Re-Check.
$raced = false;
DB::beforeExecuting(function (string $query) use (&$raced, $pressRelease, $correctedHash): void {
if ($raced || ! str_starts_with($query, 'insert') || ! str_contains($query, 'ki_audits')) {
return;
}
$raced = true;
DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Sauberer Inhalt.', 'content_hash' => $correctedHash]);
});
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $redHash))->handle(
app(ClassificationManager::class),
app(PressReleaseService::class),
);
expect($raced)->toBeTrue()
->and($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
Mail::assertNothingQueued();
Queue::assertNotPushed(ClassifyPressRelease::class);
});