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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $this->isAuthor($user, $pressRelease);
|
||||
return $this->canManage($user, $pressRelease);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
|
|
@ -34,7 +34,7 @@ class PressReleasePolicy
|
|||
|
||||
public function update(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
if (! $this->isAuthor($user, $pressRelease) && ! $user->canAccessAdmin()) {
|
||||
if (! $this->canManage($user, $pressRelease) && ! $user->canAccessAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ class PressReleasePolicy
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ class PressReleasePolicy
|
|||
return true;
|
||||
}
|
||||
|
||||
return $this->isAuthor($user, $pressRelease)
|
||||
return $this->canManage($user, $pressRelease)
|
||||
&& $pressRelease->status !== PressReleaseStatus::Published;
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +76,17 @@ class PressReleasePolicy
|
|||
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
|
||||
{
|
||||
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>
|
||||
<span>Magic-Link senden</span>
|
||||
</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>
|
||||
|
||||
{{-- 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
|
||||
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
|
||||
$user = auth()->user();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,9 +113,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
$userId = auth()->id();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
|
||||
// Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen.
|
||||
$accessibleCompanyIds = auth()->user()->accessibleCompanyIds();
|
||||
|
||||
$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 && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('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
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
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([
|
||||
'company:id,name,email,phone,address,portal,logo_path,legacy_portal,is_active,type',
|
||||
'category.translations',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
|||
->middleware(['guest:'.config('fortify.guard')])
|
||||
->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
|
||||
Volt::route('/register', 'auth.register')
|
||||
->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