WS-2: Firmen-Scope für PMs & Magic-Link-Zugang für Pressekontakte
Firmen-Scope (Fundament): - PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user- Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/ canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten alle PMs ihrer Firma. Magic-Link-Zugang für Pressekontakte (ContactAccessService): - Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume wiederverwendet) – keine Schema-Änderung, kein paralleles System. - Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später). Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup, Enumeration-Sicherheit, Honeypot). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
94cb209a9f
commit
980763c362
11 changed files with 493 additions and 7 deletions
|
|
@ -330,4 +330,29 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
|
|
||||||
return $this->hasAnyRole(['admin', 'editor', 'customer']);
|
return $this->hasAnyRole(['admin', 'editor', 'customer']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firmen, auf die dieser User Zugriff hat: als Owner (owner_user_id) oder
|
||||||
|
* als Mitglied über den company_user-Pivot. Basis für das Firmen-Scoping
|
||||||
|
* von Pressemitteilungen (Zugriff = Autor ODER Firmenzugehörigkeit).
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function accessibleCompanyIds(): array
|
||||||
|
{
|
||||||
|
return $this->ownedCompanies()->pluck('companies.id')
|
||||||
|
->merge($this->companies()->pluck('companies.id'))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessCompany(?int $companyId): bool
|
||||||
|
{
|
||||||
|
if ($companyId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($companyId, $this->accessibleCompanyIds(), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class PressReleasePolicy
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->isAuthor($user, $pressRelease);
|
return $this->canManage($user, $pressRelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
|
|
@ -34,7 +34,7 @@ class PressReleasePolicy
|
||||||
|
|
||||||
public function update(User $user, PressRelease $pressRelease): bool
|
public function update(User $user, PressRelease $pressRelease): bool
|
||||||
{
|
{
|
||||||
if (! $this->isAuthor($user, $pressRelease) && ! $user->canAccessAdmin()) {
|
if (! $this->canManage($user, $pressRelease) && ! $user->canAccessAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ class PressReleasePolicy
|
||||||
|
|
||||||
public function submitForReview(User $user, PressRelease $pressRelease): bool
|
public function submitForReview(User $user, PressRelease $pressRelease): bool
|
||||||
{
|
{
|
||||||
return $this->isAuthor($user, $pressRelease)
|
return $this->canManage($user, $pressRelease)
|
||||||
&& in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
|
&& in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ class PressReleasePolicy
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->isAuthor($user, $pressRelease)
|
return $this->canManage($user, $pressRelease)
|
||||||
&& $pressRelease->status !== PressReleaseStatus::Published;
|
&& $pressRelease->status !== PressReleaseStatus::Published;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +76,17 @@ class PressReleasePolicy
|
||||||
return $user->canAccessAdmin() && $user->can('press-releases:publish');
|
return $user->canAccessAdmin() && $user->can('press-releases:publish');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf eine PM hat der Autor ODER ein Mitglied der zugeordneten
|
||||||
|
* Firma (Owner/Team-Mitglied). So sehen/bearbeiten Firmenkontakte – inkl.
|
||||||
|
* der per Magic-Link lazy angelegten Accounts – die PMs ihrer Firma.
|
||||||
|
*/
|
||||||
|
private function canManage(User $user, PressRelease $pressRelease): bool
|
||||||
|
{
|
||||||
|
return $this->isAuthor($user, $pressRelease)
|
||||||
|
|| $user->canAccessCompany($pressRelease->company_id);
|
||||||
|
}
|
||||||
|
|
||||||
private function isAuthor(User $user, PressRelease $pressRelease): bool
|
private function isAuthor(User $user, PressRelease $pressRelease): bool
|
||||||
{
|
{
|
||||||
return $pressRelease->user_id === $user->id;
|
return $pressRelease->user_id === $user->id;
|
||||||
|
|
|
||||||
117
app/Services/Auth/ContactAccessService.php
Normal file
117
app/Services/Auth/ContactAccessService.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\RegistrationType;
|
||||||
|
use App\Mail\MagicLoginLink;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic-Link-Zugang für Pressekontakte (WS-2).
|
||||||
|
*
|
||||||
|
* Hat eine im System hinterlegte Kontakt-E-Mail Zugang angefragt, wird – lazy –
|
||||||
|
* ein Account angelegt (oder ein bestehender per E-Mail wiederverwendet), dem
|
||||||
|
* Kontakt-Firmen als Mitglied zugeordnet und ein regulärer Login-Magic-Link
|
||||||
|
* verschickt. Über den Firmen-Scope (PressReleasePolicy) verwaltet der Kontakt
|
||||||
|
* danach die PMs seiner Firma(en). Eintrittsweg in dieselbe Verwaltung wie der
|
||||||
|
* reguläre Login – kein paralleles System.
|
||||||
|
*/
|
||||||
|
class ContactAccessService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MagicLinkGenerator $magicLinks,
|
||||||
|
private readonly UserRolePermissionSyncService $roleSync,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitet eine Zugangsanfrage. Enumeration-sicher: Der Aufrufer zeigt
|
||||||
|
* unabhängig vom Ergebnis dieselbe neutrale Meldung; gibt zurück, ob ein
|
||||||
|
* Link verschickt wurde (nur für interne Tests/Logs).
|
||||||
|
*/
|
||||||
|
public function requestAccess(string $email, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
$email = mb_strtolower(trim($email));
|
||||||
|
|
||||||
|
$contacts = Contact::query()
|
||||||
|
->whereRaw('LOWER(email) = ?', [$email])
|
||||||
|
->whereNotNull('company_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($contacts->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->resolveUser($email, $contacts);
|
||||||
|
$this->linkCompanies($user, $contacts);
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = $this->magicLinks->createLoginLink($user, $ip);
|
||||||
|
|
||||||
|
Mail::to($user->email)->send(new MagicLoginLink(
|
||||||
|
user: $user,
|
||||||
|
loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]),
|
||||||
|
expiresAt: $generated['expires_at']->format('d.m.Y H:i'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy
|
||||||
|
* einen neuen anlegen. Verifizierung gilt über den Magic-Link-Kanal als
|
||||||
|
* erfüllt (Entscheidung 15.06.) – daher direkt aktiv + customer.
|
||||||
|
*
|
||||||
|
* @param Collection<int, Contact> $contacts
|
||||||
|
*/
|
||||||
|
private function resolveUser(string $email, Collection $contacts): User
|
||||||
|
{
|
||||||
|
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $contacts->first();
|
||||||
|
$name = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''));
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $name !== '' ? $name : $email,
|
||||||
|
'email' => $email,
|
||||||
|
'registration_type' => RegistrationType::Company->value,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet den User den Firmen seiner Kontakte als Mitglied zu (Firmen-Scope)
|
||||||
|
* und verknüpft ihn mit den Kontakt-Datensätzen. Owner bleiben Owner.
|
||||||
|
*
|
||||||
|
* @param Collection<int, Contact> $contacts
|
||||||
|
*/
|
||||||
|
private function linkCompanies(User $user, Collection $contacts): void
|
||||||
|
{
|
||||||
|
$companyIds = $contacts->pluck('company_id')->filter()->unique();
|
||||||
|
|
||||||
|
foreach ($companyIds as $companyId) {
|
||||||
|
$alreadyLinked = $user->ownedCompanies()->whereKey($companyId)->exists()
|
||||||
|
|| $user->companies()->whereKey($companyId)->exists();
|
||||||
|
|
||||||
|
if (! $alreadyLinked) {
|
||||||
|
$user->companies()->attach($companyId, ['role' => 'member']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
105
resources/views/livewire/auth/contact-access.blade.php
Normal file
105
resources/views/livewire/auth/contact-access.blade.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Pressemitteilung verwalten', 'eyebrow' => 'Zugang für Pressekontakte', 'topRightLabel' => 'Konto vorhanden?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||||||
|
#[Validate('required|string|email')]
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
// Honeypot gegen einfache Bots – muss leer bleiben.
|
||||||
|
public string $website = '';
|
||||||
|
|
||||||
|
public function requestAccess(ContactAccessService $contactAccess): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
if ($this->website !== '') {
|
||||||
|
// Bot: identische neutrale Antwort, keine Aktion.
|
||||||
|
$this->sent();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
RateLimiter::hit($this->throttleKey(), 600);
|
||||||
|
|
||||||
|
$contactAccess->requestAccess($this->email, request()->ip());
|
||||||
|
|
||||||
|
$this->sent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sent(): void
|
||||||
|
{
|
||||||
|
$this->reset('email');
|
||||||
|
session()->flash('status', __('Falls für diese E-Mail-Adresse ein Pressekontakt hinterlegt ist, haben wir Ihnen einen Zugangslink geschickt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', ['minutes' => ceil($seconds / 60)]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function throttleKey(): string
|
||||||
|
{
|
||||||
|
return 'contact-access|'.Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="field-status mb-4" role="status">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
|
||||||
|
Sind Sie als Pressekontakt einer Firma hinterlegt? Geben Sie Ihre
|
||||||
|
E-Mail-Adresse ein – wir senden Ihnen einen Link, mit dem Sie die
|
||||||
|
Pressemitteilungen Ihrer Firma verwalten können.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form wire:submit="requestAccess" class="space-y-[18px]" novalidate>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="contact-email">E-Mail-Adresse</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
wire:model="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="email"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="kontakt@ihre-firma.de"
|
||||||
|
@error('email') aria-invalid="true" @enderror
|
||||||
|
/>
|
||||||
|
@error('email')
|
||||||
|
<p class="field-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Honeypot: für Menschen unsichtbar --}}
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
<label for="contact-website">Website</label>
|
||||||
|
<input id="contact-website" type="text" wire:model="website" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="requestAccess">
|
||||||
|
<span wire:loading.remove wire:target="requestAccess">Zugangslink anfordern</span>
|
||||||
|
<span wire:loading wire:target="requestAccess">Wird gesendet …</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -221,6 +221,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
||||||
</svg>
|
</svg>
|
||||||
<span>Magic-Link senden</span>
|
<span>Magic-Link senden</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ route('contact-access.request') }}" class="block text-center text-[12px] text-ink-3 hover:text-hub transition-colors !mt-4" wire:navigate>
|
||||||
|
Als Pressekontakt hinterlegt? Zugang anfordern →
|
||||||
|
</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
|
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
|
||||||
|
|
|
||||||
|
|
@ -630,8 +630,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
{
|
{
|
||||||
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
|
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
|
||||||
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
|
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
|
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
|
||||||
->where('user_id', auth()->id())
|
->where(function ($q) use ($user): void {
|
||||||
|
$q->where('user_id', $user->id);
|
||||||
|
$companyIds = $user->accessibleCompanyIds();
|
||||||
|
if ($companyIds !== []) {
|
||||||
|
$q->orWhereIn('company_id', $companyIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
->findOrFail($this->id);
|
->findOrFail($this->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$context = app(CustomerCompanyContext::class);
|
$context = app(CustomerCompanyContext::class);
|
||||||
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
|
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
|
||||||
|
// Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen.
|
||||||
|
$accessibleCompanyIds = auth()->user()->accessibleCompanyIds();
|
||||||
|
|
||||||
$base = PressRelease::withoutGlobalScopes()
|
$base = PressRelease::withoutGlobalScopes()
|
||||||
->where('user_id', $userId)
|
->where(function ($q) use ($userId, $accessibleCompanyIds): void {
|
||||||
|
$q->where('user_id', $userId);
|
||||||
|
if ($accessibleCompanyIds !== []) {
|
||||||
|
$q->orWhereIn('company_id', $accessibleCompanyIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
|
->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
|
||||||
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
|
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
|
||||||
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));
|
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
|
|
||||||
private function getMyPR(): PressRelease
|
private function getMyPR(): PressRelease
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
return PressRelease::withoutGlobalScopes()
|
return PressRelease::withoutGlobalScopes()
|
||||||
->where('user_id', auth()->id())
|
->where(function ($q) use ($user): void {
|
||||||
|
$q->where('user_id', $user->id);
|
||||||
|
$companyIds = $user->accessibleCompanyIds();
|
||||||
|
if ($companyIds !== []) {
|
||||||
|
$q->orWhereIn('company_id', $companyIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
->with([
|
->with([
|
||||||
'company:id,name,email,phone,address,portal,logo_path,legacy_portal,is_active,type',
|
'company:id,name,email,phone,address,portal,logo_path,legacy_portal,is_active,type',
|
||||||
'category.translations',
|
'category.translations',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
||||||
->middleware(['guest:'.config('fortify.guard')])
|
->middleware(['guest:'.config('fortify.guard')])
|
||||||
->name('magic-links.consume');
|
->name('magic-links.consume');
|
||||||
|
|
||||||
|
// Magic-Link-Zugang für Pressekontakte ohne Account (WS-2)
|
||||||
|
Volt::route('/pressekontakt-zugang', 'auth.contact-access')
|
||||||
|
->middleware(['guest:'.config('fortify.guard')])
|
||||||
|
->name('contact-access.request');
|
||||||
|
|
||||||
// Registrierung mit Livewire
|
// Registrierung mit Livewire
|
||||||
Volt::route('/register', 'auth.register')
|
Volt::route('/register', 'auth.register')
|
||||||
->middleware(['guest:'.config('fortify.guard')])
|
->middleware(['guest:'.config('fortify.guard')])
|
||||||
|
|
|
||||||
116
tests/Feature/Auth/ContactAccessTest.php
Normal file
116
tests/Feature/Auth/ContactAccessTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\MagicLoginLink;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting access for an unknown email creates nothing and sends no mail', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$sent = app(ContactAccessService::class)->requestAccess('nobody@example.test');
|
||||||
|
|
||||||
|
expect($sent)->toBeFalse();
|
||||||
|
expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse();
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting access for a known contact lazily creates a scoped account and sends a link', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
Contact::factory()->for($company)->create([
|
||||||
|
'email' => 'paula@example.test',
|
||||||
|
'first_name' => 'Paula',
|
||||||
|
'last_name' => 'Presse',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Gross-/Kleinschreibung egal.
|
||||||
|
$sent = app(ContactAccessService::class)->requestAccess('Paula@Example.test');
|
||||||
|
|
||||||
|
expect($sent)->toBeTrue();
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'paula@example.test')->firstOrFail();
|
||||||
|
|
||||||
|
expect($user->name)->toBe('Paula Presse');
|
||||||
|
expect($user->is_active)->toBeTrue();
|
||||||
|
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||||
|
expect($user->hasRole('customer'))->toBeTrue();
|
||||||
|
expect($user->canAccessAdmin())->toBeFalse();
|
||||||
|
expect($user->canAccessCustomer())->toBeTrue();
|
||||||
|
expect($user->accessibleCompanyIds())->toContain($company->id);
|
||||||
|
|
||||||
|
Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting access reuses an existing user without creating a duplicate', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
Contact::factory()->for($company)->create([
|
||||||
|
'email' => 'existing@example.test',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existing = User::factory()->create(['email' => 'existing@example.test', 'is_active' => true]);
|
||||||
|
$existing->assignRole('customer');
|
||||||
|
|
||||||
|
app(ContactAccessService::class)->requestAccess('existing@example.test');
|
||||||
|
|
||||||
|
expect(User::query()->where('email', 'existing@example.test')->count())->toBe(1);
|
||||||
|
expect($existing->fresh()->accessibleCompanyIds())->toContain($company->id);
|
||||||
|
Mail::assertSent(MagicLoginLink::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the contact access form responds neutrally and triggers the service', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
Contact::factory()->for($company)->create([
|
||||||
|
'email' => 'form@example.test',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('auth.contact-access')
|
||||||
|
->set('email', 'form@example.test')
|
||||||
|
->call('requestAccess')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('email', '');
|
||||||
|
|
||||||
|
Mail::assertSent(MagicLoginLink::class);
|
||||||
|
expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the honeypot blocks bots without creating an account', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
Contact::factory()->for($company)->create([
|
||||||
|
'email' => 'bot-target@example.test',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('auth.contact-access')
|
||||||
|
->set('email', 'bot-target@example.test')
|
||||||
|
->set('website', 'http://spam.example')
|
||||||
|
->call('requestAccess')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
expect(User::query()->where('email', 'bot-target@example.test')->exists())->toBeFalse();
|
||||||
|
});
|
||||||
80
tests/Feature/PressReleaseCompanyScopeTest.php
Normal file
80
tests/Feature/PressReleaseCompanyScopeTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a company member can view and manage press releases authored by a colleague', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$owner = User::factory()->create(['is_active' => true]);
|
||||||
|
$owner->assignRole('customer');
|
||||||
|
|
||||||
|
$member = User::factory()->create(['is_active' => true]);
|
||||||
|
$member->assignRole('customer');
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
|
||||||
|
$member->companies()->attach($company->id, ['role' => 'member']);
|
||||||
|
|
||||||
|
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Firmen-Scope: Mitglied darf, obwohl nicht Autor.
|
||||||
|
expect($member->can('view', $pressRelease))->toBeTrue();
|
||||||
|
expect($member->can('update', $pressRelease))->toBeTrue();
|
||||||
|
expect($member->can('submitForReview', $pressRelease))->toBeTrue();
|
||||||
|
expect($member->accessibleCompanyIds())->toContain($company->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user outside the company still cannot access its press releases', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$owner = User::factory()->create(['is_active' => true]);
|
||||||
|
$owner->assignRole('customer');
|
||||||
|
|
||||||
|
$outsider = User::factory()->create(['is_active' => true]);
|
||||||
|
$outsider->assignRole('customer');
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
|
||||||
|
|
||||||
|
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($outsider->can('view', $pressRelease))->toBeFalse();
|
||||||
|
expect($outsider->can('update', $pressRelease))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the me press release detail route resolves for a company member', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$owner = User::factory()->create(['is_active' => true]);
|
||||||
|
$owner->assignRole('customer');
|
||||||
|
|
||||||
|
$member = User::factory()->create(['is_active' => true]);
|
||||||
|
$member->assignRole('customer');
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create(['owner_user_id' => $owner->id]);
|
||||||
|
$member->companies()->attach($company->id, ['role' => 'member']);
|
||||||
|
|
||||||
|
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'title' => 'Firmen-PM eines Kollegen',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($member)
|
||||||
|
->get(route('me.press-releases.show', $pressRelease->id))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Firmen-PM eines Kollegen');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue