admin: Boost vorzeitig beenden, Berechtigungsliste audit:admins
Admin ▸ Pressemitteilung zeigt laufende Boosts; „Boost beenden“ verlangt einen Grund und billing:manage, beendet laufende und angeschlossene Zeiträume, verwirft die Seiten-Caches und protokolliert „Boost beendet“. Credits werden nicht zurückgebucht. audit:admins listet alle Konten mit Rechten über den Kundenbereich hinaus (Super-Admin, Rollen außer customer/api-only, direkte Permissions), auch gesperrte und gelöschte, als Markdown mit 2FA, Passkeys, Tokens und letztem Sitzungswiderruf. E-Mail standardmäßig nur mit Domain (Nachweis BN-02). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2150d4b7ae
commit
fce7baea2b
6 changed files with 419 additions and 0 deletions
149
app/Console/Commands/AuditAdmins.php
Normal file
149
app/Console/Commands/AuditAdmins.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungsnachweis BN-02 (Rechtspaket 10/2026): listet jedes Konto, das
|
||||||
|
* über den Kundenbereich hinaus Rechte hat — Super-Admin-Merker, eine Rolle
|
||||||
|
* außer `customer`/`api-only` oder direkt vergebene Permissions —, auch
|
||||||
|
* gesperrte und gelöschte. Ausgabe als Markdown für die Ablage unter
|
||||||
|
* `dev/rechtliches/nachweise/`; jährlich wiederholen.
|
||||||
|
*
|
||||||
|
* Sitzungen liegen auf Live in Redis und lassen sich nicht je Konto zählen;
|
||||||
|
* die Liste zeigt stattdessen den letzten Widerruf (`sessions_revoked_at_ms`).
|
||||||
|
*
|
||||||
|
* E-Mail-Adressen erscheinen standardmäßig nur mit Domain, damit die Datei
|
||||||
|
* ins Repository darf. Der Befehl ändert nichts.
|
||||||
|
*/
|
||||||
|
class AuditAdmins extends Command
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private const CUSTOMER_ROLES = ['customer', 'api-only'];
|
||||||
|
|
||||||
|
protected $signature = 'audit:admins
|
||||||
|
{--with-emails : Vollständige E-Mail-Adressen statt nur der Domain ausgeben}';
|
||||||
|
|
||||||
|
protected $description = 'Listet alle Konten mit Admin-, Editor-, Super-Admin- oder Sonderrechten als Markdown (Nachweis BN-02).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$users = $this->privilegedUsers();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$this->line(sprintf('# Berechtigungen, Stand %s', $now->format('d.m.Y H:i')));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($this->summary($users, $now));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('| ID | E-Mail | Rollen | Super-Admin | Aktiv | Gelöscht | Admin-Zugang | TOTP | Passkeys | Letzte Anmeldung | API-Tokens | Sitzungen zuletzt widerrufen |');
|
||||||
|
$this->line('|---|---|---|---|---|---|---|---|---|---|---|---|');
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$this->line('| '.implode(' | ', $this->row($user)).' |');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->line('| – | – | – | – | – | – | – | – | – | – | – | – |');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Admin-Zugang = aktiv und Super-Admin oder Rolle `admin`/`editor` (`User::canAccessAdmin()`). Sitzungen liegen in Redis und sind je Konto nicht zählbar; ein Widerruf beendet alle älteren Sitzungen.');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('**Zugänge außerhalb der Anwendung** (Ploi, Hetzner-Konsole, SSH): [eintragen]');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, User>
|
||||||
|
*/
|
||||||
|
private function privilegedUsers(): Collection
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->withTrashed()
|
||||||
|
->with(['roles:id,name', 'permissions:id,name'])
|
||||||
|
->withCount(['passkeys', 'tokens'])
|
||||||
|
->withMax('tokens', 'last_used_at')
|
||||||
|
->where(fn (Builder $query) => $query
|
||||||
|
->where('is_super_admin', true)
|
||||||
|
->orWhereHas('roles', fn (Builder $roles) => $roles->whereNotIn('name', self::CUSTOMER_ROLES))
|
||||||
|
->orWhereHas('permissions'))
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, User> $users
|
||||||
|
*/
|
||||||
|
private function summary(Collection $users, Carbon $now): string
|
||||||
|
{
|
||||||
|
$withAccess = $users->filter(fn (User $user): bool => ! $user->trashed() && $user->canAccessAdmin());
|
||||||
|
$withSecondFactor = $withAccess->filter(fn (User $user): bool => $this->hasSecondFactor($user));
|
||||||
|
$editors = $withAccess->filter(fn (User $user): bool => $user->roles->contains('name', 'editor'));
|
||||||
|
$superAdmins = $withAccess->where('is_super_admin', true);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Stand %s: %d %s mit Zugang zum Admin-Bereich, davon %d mit zweitem Faktor (TOTP oder Passkey), %d Super-Admin, %d Editor-%s. Gelistet sind %d Konten mit Rechten über den Kundenbereich hinaus, einschließlich gesperrter und gelöschter.',
|
||||||
|
$now->format('d.m.Y'),
|
||||||
|
$withAccess->count(),
|
||||||
|
$withAccess->count() === 1 ? 'Konto' : 'Konten',
|
||||||
|
$withSecondFactor->count(),
|
||||||
|
$superAdmins->count(),
|
||||||
|
$editors->count(),
|
||||||
|
$editors->count() === 1 ? 'Konto' : 'Konten',
|
||||||
|
$users->count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function row(User $user): array
|
||||||
|
{
|
||||||
|
$roles = $user->roles->pluck('name')
|
||||||
|
->merge($user->permissions->pluck('name')->map(fn (string $name): string => 'direkt: '.$name))
|
||||||
|
->implode(', ');
|
||||||
|
$revokedAt = $user->sessions_revoked_at_ms
|
||||||
|
? Carbon::createFromTimestampMs($user->sessions_revoked_at_ms)->timezone(config('app.timezone'))->format('d.m.Y H:i')
|
||||||
|
: '–';
|
||||||
|
$tokenLastUsedAt = $user->getAttribute('tokens_max_last_used_at');
|
||||||
|
$tokenLastUsed = $tokenLastUsedAt
|
||||||
|
? ', zuletzt '.Carbon::parse($tokenLastUsedAt)->format('d.m.Y')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
(string) $user->id,
|
||||||
|
$this->email($user),
|
||||||
|
$roles !== '' ? $roles : '–',
|
||||||
|
$user->is_super_admin ? 'ja' : 'nein',
|
||||||
|
$user->is_active ? 'ja' : 'gesperrt',
|
||||||
|
$user->trashed() ? $user->deleted_at->format('d.m.Y') : 'nein',
|
||||||
|
! $user->trashed() && $user->canAccessAdmin() ? 'ja' : 'nein',
|
||||||
|
$user->two_factor_confirmed_at ? 'ja' : 'nein',
|
||||||
|
(string) $user->passkeys_count,
|
||||||
|
$user->last_login_at?->format('d.m.Y H:i') ?? '–',
|
||||||
|
$user->tokens_count.$tokenLastUsed,
|
||||||
|
$revokedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasSecondFactor(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->two_factor_confirmed_at !== null || $user->passkeys_count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function email(User $user): string
|
||||||
|
{
|
||||||
|
if ($this->option('with-emails')) {
|
||||||
|
return (string) $user->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '…@'.Str::after((string) $user->email, '@');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,9 @@ enum ActivityType: string
|
||||||
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
|
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
|
||||||
case BotCheckUnavailable = 'bot_check_unavailable';
|
case BotCheckUnavailable = 'bot_check_unavailable';
|
||||||
|
|
||||||
|
/** Admin hat eine gebuchte Platzierung vorzeitig beendet. */
|
||||||
|
case BoostEnded = 'boost_ended';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|
@ -77,6 +80,7 @@ enum ActivityType: string
|
||||||
self::LoginLockout => 'Anmeldesperre',
|
self::LoginLockout => 'Anmeldesperre',
|
||||||
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
||||||
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
|
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
|
||||||
|
self::BoostEnded => 'Boost beendet',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +111,7 @@ enum ActivityType: string
|
||||||
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
||||||
self::LoginLockout => 'red',
|
self::LoginLockout => 'red',
|
||||||
self::BotCheckUnavailable => 'orange',
|
self::BotCheckUnavailable => 'orange',
|
||||||
|
self::BoostEnded => 'amber',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\PressRelease;
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
use App\Enums\BoostPlacement;
|
use App\Enums\BoostPlacement;
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
|
|
@ -11,6 +12,7 @@ use App\Models\Boost;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
use App\Services\Billing\CreditPricingService;
|
use App\Services\Billing\CreditPricingService;
|
||||||
use App\Services\Billing\CreditWalletService;
|
use App\Services\Billing\CreditWalletService;
|
||||||
use App\Support\WebPageCache;
|
use App\Support\WebPageCache;
|
||||||
|
|
@ -35,6 +37,7 @@ class BoostService
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreditPricingService $pricing,
|
private readonly CreditPricingService $pricing,
|
||||||
private readonly CreditWalletService $wallet,
|
private readonly CreditWalletService $wallet,
|
||||||
|
private readonly ActivityLog $activityLog,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -180,6 +183,52 @@ class BoostService
|
||||||
return $boost;
|
return $boost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet alle laufenden und bereits angeschlossenen Boosts einer PM
|
||||||
|
* sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder
|
||||||
|
* wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als
|
||||||
|
* Buchungsnachweis erhalten; Credits werden nicht zurückgebucht —
|
||||||
|
* Erstattungen laufen getrennt.
|
||||||
|
*
|
||||||
|
* @return int Anzahl der beendeten Boosts
|
||||||
|
*/
|
||||||
|
public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$boosts = $pressRelease->boosts()->active()->get();
|
||||||
|
|
||||||
|
if ($boosts->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($boosts as $boost) {
|
||||||
|
$boost->update([
|
||||||
|
'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at,
|
||||||
|
'ends_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$boosts->pluck('placement')->unique()->each(
|
||||||
|
fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->activityLog->record(
|
||||||
|
ActivityType::BoostEnded,
|
||||||
|
sprintf(
|
||||||
|
'%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s',
|
||||||
|
$endedBy->name,
|
||||||
|
$boosts->count(),
|
||||||
|
$pressRelease->id,
|
||||||
|
$reason,
|
||||||
|
),
|
||||||
|
user: $endedBy,
|
||||||
|
subject: $pressRelease,
|
||||||
|
meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $boosts->count();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Enums\AiImageDeclaration;
|
||||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||||
use App\Enums\ImagePrecheckCategory;
|
use App\Enums\ImagePrecheckCategory;
|
||||||
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
||||||
|
use App\Services\PressRelease\BoostService;
|
||||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use App\Services\PressRelease\QuotaExceededException;
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
|
|
@ -116,6 +117,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
Flux::modal('confirm-show-archive')->close();
|
Flux::modal('confirm-show-archive')->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string $boostEndReason = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gebuchte Platzierung vorzeitig beenden (Rechtspaket 10/2026, U10):
|
||||||
|
* etwa nach einem Test-Boost oder wenn die Platzierung nicht mehr
|
||||||
|
* vertretbar ist. Ohne Rückbuchung; nur mit Abrechnungsrecht.
|
||||||
|
*/
|
||||||
|
public function endBoost(BoostService $boosts): void
|
||||||
|
{
|
||||||
|
$this->authorize('billing:manage');
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'boostEndReason' => ['required', 'string', 'min:5', 'max:490'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ended = $boosts->endActive($this->findAdminPR($this->id), auth()->user(), trim($this->boostEndReason));
|
||||||
|
|
||||||
|
$this->boostEndReason = '';
|
||||||
|
|
||||||
|
Flux::toast(
|
||||||
|
text: $ended > 0 ? __('Gebuchte Platzierung beendet.') : __('Kein laufender Boost.'),
|
||||||
|
variant: $ended > 0 ? 'success' : 'warning',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder
|
* Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder
|
||||||
* Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten.
|
* Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten.
|
||||||
|
|
@ -275,6 +301,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
'latestRejection' => $latestRejection,
|
'latestRejection' => $latestRejection,
|
||||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||||
|
'activeBoosts' => $pr->boosts()->active()->orderBy('ends_at')->get(),
|
||||||
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
||||||
?? $pr->category?->translations->first()?->name
|
?? $pr->category?->translations->first()?->name
|
||||||
?? '–',
|
?? '–',
|
||||||
|
|
@ -489,6 +516,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
</article>
|
</article>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- ============== GEBUCHTE PLATZIERUNG (BOOST) ============== --}}
|
||||||
|
@if ($activeBoosts->isNotEmpty())
|
||||||
|
<article class="panel" data-active-boosts>
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Gebuchte Platzierung') }}</span>
|
||||||
|
<span class="badge">{{ __('läuft') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-3 max-w-[760px]">
|
||||||
|
<ul class="m-0 list-disc pl-5 text-[13px]">
|
||||||
|
@foreach ($activeBoosts as $activeBoost)
|
||||||
|
<li>
|
||||||
|
{{ $activeBoost->placement->label() }} · {{ __(':days Tage', ['days' => $activeBoost->days]) }} · {{ __(':credits Credits', ['credits' => $activeBoost->credits_charged]) }}
|
||||||
|
<span class="text-[color:var(--color-ink-4)]">({{ $activeBoost->starts_at->format('d.m.Y H:i') }} – {{ $activeBoost->ends_at->format('d.m.Y H:i') }})</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@can('billing:manage')
|
||||||
|
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||||
|
{{ __('Beenden nimmt die Meldung sofort von Start- und Branchenseite. Die Buchung bleibt als Nachweis erhalten; Credits werden nicht zurückgebucht.') }}
|
||||||
|
</p>
|
||||||
|
<flux:textarea wire:model="boostEndReason" :label="__('Grund (Pflicht)')" rows="2" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<flux:button variant="danger" wire:click="endBoost" wire:confirm="{{ __('Gebuchte Platzierung jetzt beenden?') }}">{{ __('Boost beenden') }}</flux:button>
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- ============== REJECTION-HINWEIS ============== --}}
|
{{-- ============== REJECTION-HINWEIS ============== --}}
|
||||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
||||||
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
||||||
|
|
|
||||||
91
tests/Feature/Admin/EndBoostTest.php
Normal file
91
tests/Feature/Admin/EndBoostTest.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
|
use App\Models\Boost;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gebuchte Platzierung vorzeitig beenden (Rechtspaket 10/2026, U10): der
|
||||||
|
* Admin beendet laufende und angeschlossene Boosts mit Pflichtgrund; die
|
||||||
|
* Buchung bleibt als Nachweis stehen, der Vorgang landet im Verlauf.
|
||||||
|
*/
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
|
||||||
|
$this->pressRelease = PressRelease::factory()->published()->create([
|
||||||
|
'classification' => PressReleaseClassification::Green->value,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin ends running and queued boosts with a reason', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$running = Boost::factory()->for($this->pressRelease)->create();
|
||||||
|
$queued = Boost::factory()->for($this->pressRelease)->create([
|
||||||
|
'starts_at' => now()->addDays(7),
|
||||||
|
'ends_at' => now()->addDays(14),
|
||||||
|
]);
|
||||||
|
$expired = Boost::factory()->for($this->pressRelease)->expired()->create();
|
||||||
|
$expiredEnd = $expired->ends_at;
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id])
|
||||||
|
->assertSee('Boost beenden')
|
||||||
|
->set('boostEndReason', 'Test-Boost für die Screenshots beendet')
|
||||||
|
->call('endBoost')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($this->pressRelease->boosts()->active()->exists())->toBeFalse()
|
||||||
|
->and($running->fresh()->ends_at->lessThanOrEqualTo(now()))->toBeTrue()
|
||||||
|
->and($queued->fresh()->starts_at->lessThanOrEqualTo($queued->fresh()->ends_at))->toBeTrue()
|
||||||
|
->and($expired->fresh()->ends_at->equalTo($expiredEnd))->toBeTrue()
|
||||||
|
->and(Boost::query()->count())->toBe(3);
|
||||||
|
|
||||||
|
$event = ActivityEvent::query()->where('type', ActivityType::BoostEnded->value)->sole();
|
||||||
|
expect($event->description)->toContain('Test-Boost für die Screenshots beendet')
|
||||||
|
->and($event->meta['boost_ids'])->toEqualCanonicalizing([$running->id, $queued->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ending a boost requires a reason', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
Boost::factory()->for($this->pressRelease)->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id])
|
||||||
|
->set('boostEndReason', '')
|
||||||
|
->call('endBoost')
|
||||||
|
->assertHasErrors(['boostEndReason']);
|
||||||
|
|
||||||
|
expect($this->pressRelease->boosts()->active()->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an editor sees the boost but cannot end it', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$editor->assignRole('editor');
|
||||||
|
Boost::factory()->for($this->pressRelease)->create();
|
||||||
|
|
||||||
|
$this->actingAs($editor);
|
||||||
|
|
||||||
|
Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id])
|
||||||
|
->assertSee('Gebuchte Platzierung')
|
||||||
|
->assertDontSee('Boost beenden')
|
||||||
|
->set('boostEndReason', 'Versuch ohne Abrechnungsrecht')
|
||||||
|
->call('endBoost')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($this->pressRelease->boosts()->active()->exists())->toBeTrue();
|
||||||
|
});
|
||||||
69
tests/Feature/Auth/AdminAuditTest.php
Normal file
69
tests/Feature/Auth/AdminAuditTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungsnachweis BN-02: `audit:admins` listet jedes Konto mit Rechten
|
||||||
|
* über den Kundenbereich hinaus als Markdown, standardmäßig ohne vollständige
|
||||||
|
* E-Mail-Adresse, und ändert dabei nichts.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the audit lists admins, editors, super admins and locked or deleted privileged accounts', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$admin = User::factory()->create(['email' => 'betrieb@pressekonto.test', 'last_login_at' => now()]);
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
DB::table('passkeys')->insert([
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
'name' => 'Laptop',
|
||||||
|
'credential_id' => 'cred-1',
|
||||||
|
'credential' => '{}',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$editor = User::factory()->create(['email' => 'redaktion@example.test']);
|
||||||
|
$editor->assignRole('editor');
|
||||||
|
|
||||||
|
$superAdmin = User::factory()->superAdmin()->create(['email' => 'alt@me.test']);
|
||||||
|
$superAdmin->assignRole('customer');
|
||||||
|
$superAdmin->createToken('Legacy-API-Key (importiert)');
|
||||||
|
|
||||||
|
$locked = User::factory()->create(['email' => 'gesperrt@example.test', 'is_active' => false]);
|
||||||
|
$locked->assignRole('admin');
|
||||||
|
|
||||||
|
$deleted = User::factory()->create(['email' => 'geloescht@example.test']);
|
||||||
|
$deleted->assignRole('admin');
|
||||||
|
$deleted->delete();
|
||||||
|
|
||||||
|
$customer = User::factory()->create(['email' => 'kunde@example.test']);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
|
||||||
|
$this->artisan('audit:admins')
|
||||||
|
->expectsOutputToContain('3 Konten mit Zugang zum Admin-Bereich, davon 1 mit zweitem Faktor (TOTP oder Passkey), 1 Super-Admin, 1 Editor-Konto. Gelistet sind 5 Konten')
|
||||||
|
->expectsOutputToContain('| '.$admin->id.' | …@pressekonto.test | admin | nein | ja | nein | ja | nein | 1 |')
|
||||||
|
->expectsOutputToContain('| '.$superAdmin->id.' | …@me.test | customer | ja | ja | nein | ja |')
|
||||||
|
->expectsOutputToContain('| '.$locked->id.' | …@example.test | admin | nein | gesperrt | nein | nein |')
|
||||||
|
->expectsOutputToContain('| '.$deleted->id.' | …@example.test | admin | nein | ja | '.now()->format('d.m.Y').' | nein |')
|
||||||
|
->doesntExpectOutputToContain('| '.$customer->id.' |')
|
||||||
|
->doesntExpectOutputToContain('betrieb@pressekonto.test')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect($superAdmin->fresh()->tokens()->count())->toBe(1)
|
||||||
|
->and($deleted->fresh()->trashed())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the audit prints full email addresses only on request', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$admin = User::factory()->create(['email' => 'betrieb@pressekonto.test']);
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->artisan('audit:admins', ['--with-emails' => true])
|
||||||
|
->expectsOutputToContain('| '.$admin->id.' | betrieb@pressekonto.test | admin |')
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue