From 87c49de6e5aca6ae5b7c8df5e4a41250816cd8ec Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 10:31:43 +0200 Subject: [PATCH] Move the console's identity out of the customer table --- .../Controllers/ImpersonationController.php | 22 ++- app/Http/Middleware/EnsureAdmin.php | 13 +- app/Http/Middleware/EnsureCustomerActive.php | 8 +- app/Http/Middleware/PublicSiteGate.php | 3 +- app/Livewire/Admin/InstanceAdminAccess.php | 9 +- app/Livewire/Admin/Maintenance.php | 3 +- app/Livewire/Admin/Secrets.php | 3 +- app/Livewire/Admin/Settings.php | 65 ++++---- app/Livewire/Admin/Vpn.php | 27 +-- app/Livewire/Admin/VpnConfigAccess.php | 11 +- app/Livewire/Concerns/ChangesOwnPassword.php | 71 ++++++-- app/Models/Customer.php | 3 +- app/Models/User.php | 18 +- app/Models/VpnPeer.php | 4 +- app/Policies/VpnPeerPolicy.php | 43 +++-- app/Services/Secrets/SecretVault.php | 4 +- database/factories/UserFactory.php | 19 --- database/factories/VpnPeerFactory.php | 8 +- ..._29_100000_move_rbac_to_operator_guard.php | 154 ++++++++++++++++++ ...nt_operator_owned_columns_at_operators.php | 66 ++++++++ resources/views/layouts/portal-app.blade.php | 4 +- routes/channels.php | 3 +- tests/Feature/Admin/AdminConsoleTest.php | 7 +- .../Admin/AdminHostRestrictionTest.php | 20 +-- tests/Feature/Admin/AdminSettingsTest.php | 33 ++-- tests/Feature/Admin/ConsoleNetworkTest.php | 20 +-- .../Admin/ConsoleReportsRealDataTest.php | 14 +- tests/Feature/Admin/CustomerRevenueTest.php | 9 +- tests/Feature/Admin/DatacenterTest.php | 32 ++-- tests/Feature/Admin/HostManagementTest.php | 26 +-- .../Feature/Admin/InstanceAdminAccessTest.php | 8 +- tests/Feature/Admin/MaintenanceTest.php | 16 +- tests/Feature/Admin/PlanAdminTest.php | 62 +++---- .../Feature/Admin/ProvisioningActionsTest.php | 4 +- tests/Feature/Admin/RbacMoveTest.php | 76 +++++++++ tests/Feature/Admin/RbacTest.php | 25 ++- tests/Feature/Admin/SecretVaultTest.php | 26 +-- tests/Feature/Admin/SecretsPageTest.php | 14 +- tests/Feature/Admin/UpdateButtonTest.php | 30 ++-- tests/Feature/Admin/VpnTest.php | 98 ++++++----- .../ConsoleLoginLandsInTheConsoleTest.php | 24 ++- tests/Feature/Auth/RegisterTest.php | 4 +- tests/Feature/ImpersonationTest.php | 14 +- tests/Feature/Mail/MailSettingsPageTest.php | 148 ++++++++--------- tests/Feature/OperatorInPortalTest.php | 63 ------- .../Feature/Provisioning/LiveProgressTest.php | 5 +- tests/Feature/PublicSiteGateTest.php | 21 ++- tests/Feature/ReleaseVersionTest.php | 6 +- tests/Pest.php | 19 ++- 49 files changed, 868 insertions(+), 517 deletions(-) create mode 100644 database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php create mode 100644 database/migrations/2026_07_29_100001_point_operator_owned_columns_at_operators.php create mode 100644 tests/Feature/Admin/RbacMoveTest.php delete mode 100644 tests/Feature/OperatorInPortalTest.php diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php index 8eabde8..d81afd1 100644 --- a/app/Http/Controllers/ImpersonationController.php +++ b/app/Http/Controllers/ImpersonationController.php @@ -3,13 +3,20 @@ namespace App\Http\Controllers; use App\Models\Customer; -use App\Models\User; +use App\Models\Operator; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; /** * Admin impersonation: an admin can log in as a customer's portal account to * inspect their portal during an incident, then return to the admin session. + * + * Both guards are named explicitly throughout, never the ambient default: the + * operator and the customer are two different models on two different guards + * now, and this is exactly the code that swaps which one is "the" signed-in + * user for the request. The full signed-link rewrite (for hosts that do not + * share a cookie at all) is a separate piece of work; this keeps today's + * same-session handover correct across the split identity in the meantime. */ class ImpersonationController extends Controller { @@ -19,8 +26,8 @@ class ImpersonationController extends Controller $this->authorize('customers.impersonate'); $user = $customer->ensureUser(); - session(['impersonator_id' => Auth::id()]); - Auth::login($user); + session(['impersonator_id' => Auth::guard('operator')->id()]); + Auth::guard('web')->login($user); return redirect()->route('dashboard'); } @@ -28,10 +35,13 @@ class ImpersonationController extends Controller /** Return to the admin session (available while impersonating). */ public function leave(): RedirectResponse { - $adminId = session()->pull('impersonator_id'); + $operatorId = session()->pull('impersonator_id'); - if ($adminId !== null && ($admin = User::query()->find($adminId)) !== null) { - Auth::login($admin); + if ($operatorId !== null && ($operator = Operator::query()->find($operatorId)) !== null) { + // Ends the customer session rather than leaving it dangling + // alongside the restored operator one. + Auth::guard('web')->logout(); + Auth::guard('operator')->login($operator); return redirect()->route('admin.overview'); } diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php index b5e61ae..0f75874 100644 --- a/app/Http/Middleware/EnsureAdmin.php +++ b/app/Http/Middleware/EnsureAdmin.php @@ -4,19 +4,22 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; class EnsureAdmin { /** - * Only operators with the console.view capability may reach the admin console. - * Legacy is_admin accounts are migrated to roles by the seed_roles_and_permissions - * migration and the seeder — the gate never trusts the bare is_admin flag, so - * an RBAC revocation is never silently undone here. + * Only operators with the console.view capability may reach the admin + * console. Checked on the operator guard explicitly, never on the default + * (`web`) guard's user — a portal account can no longer hold a role at all, + * so trusting the default guard here would just mean nobody ever gets in. */ public function handle(Request $request, Closure $next): Response { - abort_unless((bool) $request->user()?->can('console.view'), 403); + $operator = Auth::guard('operator')->user(); + + abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403); return $next($request); } diff --git a/app/Http/Middleware/EnsureCustomerActive.php b/app/Http/Middleware/EnsureCustomerActive.php index 8682a54..47604c8 100644 --- a/app/Http/Middleware/EnsureCustomerActive.php +++ b/app/Http/Middleware/EnsureCustomerActive.php @@ -10,8 +10,10 @@ use Symfony\Component\HttpFoundation\Response; /** * Enforce customer account lifecycle on portal requests: a suspended or closed - * customer must lose access. Admins are exempt, and an active impersonation - * session is exempt so operators can still inspect a suspended customer's portal. + * customer must lose access. An active impersonation session is exempt so + * operators can still inspect a suspended customer's portal. There is no + * operator special case here any more: a `web`-guard user can never be an + * operator, so the only thing left to exempt is an impersonation session. */ class EnsureCustomerActive { @@ -19,7 +21,7 @@ class EnsureCustomerActive { $user = $request->user(); - if ($user !== null && ! $user->isOperator() && ! $request->session()->has('impersonator_id')) { + if ($user !== null && ! $request->session()->has('impersonator_id')) { $customer = Customer::query()->where('user_id', $user->id)->first() ?? Customer::query()->where('email', $user->email)->first(); diff --git a/app/Http/Middleware/PublicSiteGate.php b/app/Http/Middleware/PublicSiteGate.php index 85e6b00..411c838 100644 --- a/app/Http/Middleware/PublicSiteGate.php +++ b/app/Http/Middleware/PublicSiteGate.php @@ -6,6 +6,7 @@ use App\Support\AdminArea; use App\Support\Settings; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\Response; @@ -56,7 +57,7 @@ class PublicSiteGate return $next($request); } - if ($this->fromManagementNetwork($request) || $request->user()?->isOperator()) { + if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) { return $next($request); } diff --git a/app/Livewire/Admin/InstanceAdminAccess.php b/app/Livewire/Admin/InstanceAdminAccess.php index ba52e75..0115aaf 100644 --- a/app/Livewire/Admin/InstanceAdminAccess.php +++ b/app/Livewire/Admin/InstanceAdminAccess.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Models\Instance; use App\Provisioning\Jobs\IssueInstanceAdminAccess; use App\Services\Wireguard\ConfigHandoff; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; @@ -54,14 +55,16 @@ class InstanceAdminAccess extends ModalComponent $this->authorize('instances.adminlogin'); $this->resetErrorBag('password'); - $key = 'instance-admin:'.auth()->id(); + $operator = Auth::guard('operator')->user(); + + $key = 'instance-admin:'.$operator?->id; if (RateLimiter::tooManyAttempts($key, 5)) { $this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])); return; } - if (! Hash::check($this->password, auth()->user()->password)) { + if (! Hash::check($this->password, $operator->password)) { RateLimiter::hit($key, 300); $this->addError('password', __('instances.wrong_password')); @@ -76,7 +79,7 @@ class InstanceAdminAccess extends ModalComponent $this->token = Str::random(40); $this->waiting = true; - IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, (int) auth()->id()); + IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, $operator->id); } public function render() diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index afa2bff..6ac7bc8 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -8,6 +8,7 @@ use App\Models\Host; use App\Models\MaintenanceWindow; use App\Services\Maintenance\MaintenanceNotifier; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; @@ -143,7 +144,7 @@ class Maintenance extends Component 'starts_at' => $starts, 'ends_at' => $ends, 'state' => $state, - 'created_by' => auth()->id(), + 'created_by' => Auth::guard('operator')->id(), 'published_at' => $state === 'scheduled' ? now() : null, ]); $window->hosts()->sync(array_map('intval', $data['hostIds'] ?? [])); diff --git a/app/Livewire/Admin/Secrets.php b/app/Livewire/Admin/Secrets.php index c024c4b..07117ac 100644 --- a/app/Livewire/Admin/Secrets.php +++ b/app/Livewire/Admin/Secrets.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Livewire\Concerns\ConfirmsPassword; use App\Services\Secrets\SecretVault; use App\Services\Stripe\StripeCheck; +use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Component; use Throwable; @@ -62,7 +63,7 @@ class Secrets extends Component } try { - app(SecretVault::class)->put($key, $value, auth()->user()); + app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user()); } catch (Throwable $e) { $this->addError('entered.'.$field, $e->getMessage()); diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index f435fc4..9c8e86a 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -3,11 +3,12 @@ namespace App\Livewire\Admin; use App\Models\Customer; -use App\Models\User; +use App\Models\Operator; use App\Models\VpnPeer; use App\Services\Deployment\UpdateChannel; use App\Support\Settings as AppSettings; use App\Provisioning\Jobs\ApplyVpnPeer; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -26,6 +27,12 @@ class Settings extends Component { use \App\Livewire\Concerns\ChangesOwnPassword; + /** This page changes the signed-in OPERATOR's password, never a portal one. */ + protected function passwordAccountGuard(): string + { + return 'operator'; + } + // My account #[Validate('required|string|max:255')] public string $name = ''; @@ -50,8 +57,8 @@ class Settings extends Component public function mount(): void { - $this->name = auth()->user()->name; - $this->email = auth()->user()->email; + $this->name = Auth::guard('operator')->user()->name; + $this->email = Auth::guard('operator')->user()->email; } /** @@ -73,10 +80,10 @@ class Settings extends Component public function saveAccount(): void { - $user = auth()->user(); + $user = Auth::guard('operator')->user(); $data = $this->validate([ 'name' => 'required|string|max:255', - 'email' => 'required|email|max:255|unique:users,email,'.$user->id, + 'email' => 'required|email|max:255|unique:operators,email,'.$user->id, ]); // An operator email must never collide with a customer's — that would @@ -96,12 +103,12 @@ class Settings extends Component $this->authorize('staff.manage'); $data = $this->validate([ 'staffName' => 'required|string|max:255', - 'staffEmail' => 'required|email|max:255|unique:users,email', + 'staffEmail' => 'required|email|max:255|unique:operators,email', 'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only', ]); // Only an Owner may create another Owner. - if ($data['staffRole'] === 'Owner' && ! auth()->user()->hasRole('Owner')) { + if ($data['staffRole'] === 'Owner' && ! Auth::guard('operator')->user()->hasRole('Owner')) { $this->addError('staffRole', __('admin_settings.owner_only')); return; @@ -117,13 +124,12 @@ class Settings extends Component // password and surface it once to the Owner to share securely — the // account is usable immediately (a proper invite link follows with mail). $temp = Str::password(14); - $user = User::create([ + $operator = Operator::create([ 'name' => $data['staffName'], 'email' => $data['staffEmail'], 'password' => Hash::make($temp), - 'is_admin' => true, ]); - $user->assignRole($data['staffRole']); + $operator->assignRole($data['staffRole']); $this->invitedEmail = $data['staffEmail']; $this->invitedPassword = $temp; @@ -135,7 +141,7 @@ class Settings extends Component public function setStaffRole(int $id, string $role): void { $this->authorize('staff.manage'); - if (! in_array($role, User::OPERATOR_ROLES, true)) { + if (! in_array($role, Operator::OPERATOR_ROLES, true)) { return; } @@ -144,20 +150,22 @@ class Settings extends Component // raced to zero by concurrent demotions of different owners. Role::query()->where('name', 'Owner')->lockForUpdate()->first(); - $target = User::query()->whereKey($id)->lockForUpdate()->first(); + $target = Operator::query()->whereKey($id)->lockForUpdate()->first(); if ($target === null) { return 'gone'; } - if ($target->id === auth()->id()) { + if ($target->id === Auth::guard('operator')->id()) { return 'self'; } - // Only existing operators may be re-roled — never escalate a customer - // (or any non-staff) user into the console via a tampered id. + // Only existing operators may be re-roled. The Customer check below + // can no longer fire in practice — operators and customers are + // different tables now, so a tampered id cannot resolve to one from + // the other — but it costs nothing to leave as a second line. if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) { return 'not_staff'; } // Granting or revoking Owner is Owner-only. - if (($role === 'Owner' || $target->hasRole('Owner')) && ! auth()->user()->hasRole('Owner')) { + if (($role === 'Owner' || $target->hasRole('Owner')) && ! Auth::guard('operator')->user()->hasRole('Owner')) { return 'owner_only'; } // Never demote the last Owner. @@ -181,18 +189,17 @@ class Settings extends Component $result = DB::transaction(function () use ($id, &$revokedPeers) { Role::query()->where('name', 'Owner')->lockForUpdate()->first(); - $target = User::query()->whereKey($id)->lockForUpdate()->first(); + $target = Operator::query()->whereKey($id)->lockForUpdate()->first(); if ($target === null || ! $target->isOperator()) { return 'gone'; } - if ($target->id === auth()->id()) { + if ($target->id === Auth::guard('operator')->id()) { return 'self'; } if ($target->hasRole('Owner') && $this->ownerCount() <= 1) { return 'last_owner'; } $target->syncRoles([]); - $target->update(['is_admin' => false]); // Taking away the console but leaving the tunnel would be the worst // of both: no access to the panel, still a route into the @@ -235,7 +242,7 @@ class Settings extends Component private function ownerCount(): int { - return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count(); + return Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count(); } /** A new entry for the console allowlist: a single address or a CIDR range. */ @@ -337,7 +344,7 @@ class Settings extends Component { $this->authorize('site.manage'); - $accepted = app(UpdateChannel::class)->request(auth()->user()->email); + $accepted = app(UpdateChannel::class)->request(Auth::guard('operator')->user()->email); $this->dispatch('notify', message: __($accepted ? 'admin_settings.update_requested' @@ -346,24 +353,24 @@ class Settings extends Component public function render() { - $staff = User::query() - ->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES)) + $staff = Operator::query() + ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) ->with('roles') ->orderBy('name') ->get() - ->map(fn (User $u) => [ + ->map(fn (Operator $u) => [ 'id' => $u->id, 'name' => $u->name, 'email' => $u->email, 'role' => $u->roles->first()?->name ?? '—', - 'self' => $u->id === auth()->id(), + 'self' => $u->id === Auth::guard('operator')->id(), ]); return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), 'sitePublic' => AppSettings::bool('site.public', true), - 'canManageSite' => auth()->user()?->can('site.manage') ?? false, + 'canManageSite' => Auth::guard('operator')->user()?->can('site.manage') ?? false, // Shown so nobody has to guess why they still see the real site. 'viewerOnVpn' => \Symfony\Component\HttpFoundation\IpUtils::checkIp( (string) request()->ip(), @@ -376,9 +383,9 @@ class Settings extends Component 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), 'viewerIp' => (string) request()->ip(), 'staff' => $staff, - 'roles' => User::OPERATOR_ROLES, - 'canManageStaff' => auth()->user()->can('staff.manage'), - 'isOwner' => auth()->user()->hasRole('Owner'), + 'roles' => Operator::OPERATOR_ROLES, + 'canManageStaff' => Auth::guard('operator')->user()->can('staff.manage'), + 'isOwner' => Auth::guard('operator')->user()->hasRole('Owner'), ]); } } diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index 1c75128..969c4eb 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -3,16 +3,17 @@ namespace App\Livewire\Admin; use App\Models\Host; +use App\Models\Operator; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Provisioning\Jobs\SyncVpnPeers; -use App\Models\User; use App\Services\Wireguard\ConfigHandoff; use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\Keypair; use App\Services\Wireguard\QrCode; use App\Services\Wireguard\WireguardHub; use Illuminate\Database\QueryException; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -50,7 +51,7 @@ class Vpn extends Component public function mount(): void { $this->authorize('viewAny', VpnPeer::class); - $this->ownerId = auth()->id(); + $this->ownerId = Auth::guard('operator')->id(); } /** @@ -72,8 +73,10 @@ class Vpn extends Component 'name' => 'required|string|max:255', 'publicKey' => 'nullable|string|max:64', // An access belongs to a person, and only to an operator: a customer - // account must never own a way into the management network. - 'ownerId' => ['required', Rule::exists('users', 'id')], + // account must never own a way into the management network — and + // now cannot even present as one, since operators live on their + // own table. + 'ownerId' => ['required', Rule::exists('operators', 'id')], ]); // Storing needs a key. Without one we would either write the credential @@ -117,7 +120,7 @@ class Vpn extends Component // locks the same row: without that, a revocation could commit // between the check and the insert, find no peer to remove, and // leave the revoked colleague with a brand-new tunnel. - $owner = User::query()->whereKey($this->ownerId)->lockForUpdate()->first(); + $owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first(); if ($owner === null || ! $owner->isOperator()) { return 'owner_must_be_operator'; } @@ -162,7 +165,7 @@ class Vpn extends Component 'config_secret' => $secret, 'enabled' => true, 'present' => false, - 'created_by' => auth()->id(), + 'created_by' => Auth::guard('operator')->id(), ]); }); }); @@ -195,7 +198,7 @@ class Vpn extends Component } $this->reset('name', 'publicKey', 'storeConfig'); - $this->ownerId = auth()->id(); + $this->ownerId = Auth::guard('operator')->id(); $this->dispatch('notify', message: __('vpn.created')); } @@ -286,8 +289,8 @@ class Vpn extends Component { $query = VpnPeer::query(); - if (! auth()->user()?->can('vpn.view.all')) { - $query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', auth()->id()); + if (! Auth::guard('operator')->user()?->can('vpn.view.all')) { + $query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', Auth::guard('operator')->id()); } return $query; @@ -376,10 +379,10 @@ class Vpn extends Component 'newConfig' => $config = ConfigHandoff::get($this->configToken), 'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null, 'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(), - 'operators' => User::query() - ->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES)) + 'operators' => Operator::query() + ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) ->orderBy('name')->get(['id', 'name', 'email']), - 'canManage' => auth()->user()?->can('vpn.manage.all') ?? false, + 'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false, 'vaultAvailable' => ConfigVault::available(), 'hubEndpoint' => $hub->endpoint(), 'hubPublicKey' => $hub->publicKey(), diff --git a/app/Livewire/Admin/VpnConfigAccess.php b/app/Livewire/Admin/VpnConfigAccess.php index c9179fb..52972fd 100644 --- a/app/Livewire/Admin/VpnConfigAccess.php +++ b/app/Livewire/Admin/VpnConfigAccess.php @@ -6,6 +6,7 @@ use App\Models\VpnPeer; use App\Services\Wireguard\ConfigHandoff; use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\QrCode; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; @@ -69,18 +70,20 @@ class VpnConfigAccess extends ModalComponent $peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail(); $this->authorize('downloadConfig', $peer); + $operator = Auth::guard('operator')->user(); + // Rate-limited per user, so a stolen session cannot grind the password. - $key = 'vpn-config:'.auth()->id(); + $key = 'vpn-config:'.$operator?->id; if (RateLimiter::tooManyAttempts($key, 5)) { $this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])); return; } - if (! Hash::check($this->password, auth()->user()->password)) { + if (! Hash::check($this->password, $operator->password)) { RateLimiter::hit($key, 300); Log::warning('VPN config download refused: wrong password', [ - 'user_id' => auth()->id(), 'peer' => $peer->uuid, + 'user_id' => $operator->id, 'peer' => $peer->uuid, ]); $this->addError('password', __('vpn.wrong_password')); @@ -105,7 +108,7 @@ class VpnConfigAccess extends ModalComponent 'download_count' => DB::raw('download_count + 1'), ]); - Log::info('VPN config downloaded', ['user_id' => auth()->id(), 'peer' => $peer->uuid]); + Log::info('VPN config downloaded', ['user_id' => $operator->id, 'peer' => $peer->uuid]); $this->reset('password'); $this->token = ConfigHandoff::put($plaintext); diff --git a/app/Livewire/Concerns/ChangesOwnPassword.php b/app/Livewire/Concerns/ChangesOwnPassword.php index aa18ccd..fd09196 100644 --- a/app/Livewire/Concerns/ChangesOwnPassword.php +++ b/app/Livewire/Concerns/ChangesOwnPassword.php @@ -2,8 +2,11 @@ namespace App\Livewire\Concerns; +use App\Actions\Fortify\PasswordValidationRules; use App\Actions\Fortify\UpdateUserPassword; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; /** @@ -16,16 +19,34 @@ use Illuminate\Validation\ValidationException; * how one of them ends up weaker. * * Goes through Fortify's own action rather than hashing here, so the password - * rules stay in the single place that already defines them. + * rules stay in the single place that already defines them — on the `web` + * guard, which is the only one UpdateUserPassword knows: it is typed to + * `User` and pins its own `current_password` rule to `web`. An operator + * validates against the SAME PasswordValidationRules and writes its own hash + * here instead, rather than routing through an action written for a + * different model on a different guard. */ trait ChangesOwnPassword { + use PasswordValidationRules; + public string $currentPassword = ''; public string $newPassword = ''; public string $newPasswordConfirmation = ''; + /** + * Which guard's account this component changes the password of. + * + * Overridden to 'operator' by the console's Settings page. A default of + * 'web' keeps the portal working unchanged. + */ + protected function passwordAccountGuard(): string + { + return 'web'; + } + public function updateOwnPassword(): void { // The current password is asked for, and checked here as well as in the @@ -37,28 +58,46 @@ trait ChangesOwnPassword 'newPasswordConfirmation' => 'required|string', ]); - if (! Hash::check($this->currentPassword, auth()->user()->password)) { + $guard = $this->passwordAccountGuard(); + $account = Auth::guard($guard)->user(); + + if (! Hash::check($this->currentPassword, $account->password)) { $this->addError('currentPassword', __('admin_settings.password_wrong')); return; } - try { - app(UpdateUserPassword::class)->update(auth()->user(), [ - 'current_password' => $this->currentPassword, - 'password' => $this->newPassword, - 'password_confirmation' => $this->newPasswordConfirmation, - ]); - } catch (ValidationException $e) { - foreach ($e->errors() as $field => $messages) { - $this->addError(match ($field) { - 'current_password' => 'currentPassword', - 'password' => 'newPassword', - default => $field, - }, $messages[0]); + if ($guard === 'web') { + try { + app(UpdateUserPassword::class)->update($account, [ + 'current_password' => $this->currentPassword, + 'password' => $this->newPassword, + 'password_confirmation' => $this->newPasswordConfirmation, + ]); + } catch (ValidationException $e) { + foreach ($e->errors() as $field => $messages) { + $this->addError(match ($field) { + 'current_password' => 'currentPassword', + 'password' => 'newPassword', + default => $field, + }, $messages[0]); + } + + return; + } + } else { + try { + Validator::make([ + 'password' => $this->newPassword, + 'password_confirmation' => $this->newPasswordConfirmation, + ], ['password' => $this->passwordRules()])->validate(); + } catch (ValidationException $e) { + $this->addError('newPassword', $e->errors()['password'][0]); + + return; } - return; + $account->forceFill(['password' => Hash::make($this->newPassword)])->save(); } $this->reset('currentPassword', 'newPassword', 'newPasswordConfirmation'); diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 75df32d..56cee53 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use RuntimeException; @@ -135,7 +136,7 @@ class Customer extends Model private function assertNotAdmin(User $user): void { - if ($user->is_admin || $user->isOperator()) { + if (Auth::guard('operator')->check()) { throw new RuntimeException( "Refusing to link operator account {$user->email} as a portal login for customer {$this->id}.", ); diff --git a/app/Models/User.php b/app/Models/User.php index 1df9bc7..72a40f9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,23 +10,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; -use Spatie\Permission\Traits\HasRoles; -#[Fillable(['name', 'email', 'password', 'is_admin'])] +#[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable; - - /** The five operator roles that grant access to the admin console. */ - public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer']; - - /** True when this user holds an operator role (the RBAC console boundary). */ - public function isOperator(): bool - { - return $this->hasAnyRole(self::OPERATOR_ROLES); - } + use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. @@ -38,7 +28,9 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', - 'is_admin' => 'boolean', + // is_admin itself is dead: nothing reads it any more (see + // Operator/the operator guard). The column stays because dropping + // it is a schema change this task does not need to make. ]; } } diff --git a/app/Models/VpnPeer.php b/app/Models/VpnPeer.php index 7deb45e..2d1e7df 100644 --- a/app/Models/VpnPeer.php +++ b/app/Models/VpnPeer.php @@ -56,13 +56,13 @@ class VpnPeer extends Model public function creator(): BelongsTo { - return $this->belongsTo(User::class, 'created_by'); + return $this->belongsTo(Operator::class, 'created_by'); } /** The person this access belongs to (staff peers only). */ public function owner(): BelongsTo { - return $this->belongsTo(User::class, 'user_id'); + return $this->belongsTo(Operator::class, 'user_id'); } public function hasStoredConfig(): bool diff --git a/app/Policies/VpnPeerPolicy.php b/app/Policies/VpnPeerPolicy.php index 649f8da..85720cf 100644 --- a/app/Policies/VpnPeerPolicy.php +++ b/app/Policies/VpnPeerPolicy.php @@ -2,7 +2,7 @@ namespace App\Policies; -use App\Models\User; +use App\Models\Operator; use App\Models\VpnPeer; /** @@ -16,42 +16,49 @@ use App\Models\VpnPeer; * There is no Gate::before super-admin shortcut in this application, so these * rules are the whole story — in particular downloadConfig() cannot be * out-voted by a role. + * + * Every method here takes an Operator, not a User: a VPN access is a route + * into the management network, and nothing about it is customer-facing — the + * owner column has only ever held an operator's id (VpnPeerFactory's default + * is `Operator::factory()->role('Admin')`, and Vpn::create() only ever writes + * the id of an operator's own account). There is no second, customer-shaped + * path to branch this into. */ class VpnPeerPolicy { - public function viewAny(User $user): bool + public function viewAny(Operator $operator): bool { // Every operator may at least reach the page to find their own access. - return $user->isOperator(); + return $operator->isOperator(); } - public function view(User $user, VpnPeer $peer): bool + public function view(Operator $operator, VpnPeer $peer): bool { - return $this->owns($user, $peer) || $user->can('vpn.view.all'); + return $this->owns($operator, $peer) || $operator->can('vpn.view.all'); } - public function create(User $user): bool + public function create(Operator $operator): bool { // Not self-service: an access reaches the management network, so issuing // one is an administrative act even for oneself. - return $user->can('vpn.manage.all'); + return $operator->can('vpn.manage.all'); } /** Rename, reassign, change stored-config settings. */ - public function update(User $user, VpnPeer $peer): bool + public function update(Operator $operator, VpnPeer $peer): bool { - return $user->can('vpn.manage.all'); + return $operator->can('vpn.manage.all'); } /** Block / unblock. Owners may switch their own access off and on again. */ - public function block(User $user, VpnPeer $peer): bool + public function block(Operator $operator, VpnPeer $peer): bool { - return $user->can('vpn.manage.all') || $this->owns($user, $peer); + return $operator->can('vpn.manage.all') || $this->owns($operator, $peer); } - public function delete(User $user, VpnPeer $peer): bool + public function delete(Operator $operator, VpnPeer $peer): bool { - return $user->can('vpn.manage.all') || $this->owns($user, $peer); + return $operator->can('vpn.manage.all') || $this->owns($operator, $peer); } /** @@ -60,9 +67,9 @@ class VpnPeerPolicy * revokes this one and issues themselves their own, which keeps the record * of who holds what intact. */ - public function downloadConfig(User $user, VpnPeer $peer): bool + public function downloadConfig(Operator $operator, VpnPeer $peer): bool { - return $this->owns($user, $peer) + return $this->owns($operator, $peer) && $peer->config_secret !== null && ! $peer->trashed(); } @@ -73,11 +80,11 @@ class VpnPeerPolicy * the console must close these doors by itself, not rely on whoever removed * the role also remembering the tunnel. */ - private function owns(User $user, VpnPeer $peer): bool + private function owns(Operator $operator, VpnPeer $peer): bool { - return $user->isOperator() + return $operator->isOperator() && $peer->kind === VpnPeer::KIND_STAFF && $peer->user_id !== null - && $peer->user_id === $user->id; + && $peer->user_id === $operator->id; } } diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index d04d01b..cdba487 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -2,7 +2,7 @@ namespace App\Services\Secrets; -use App\Models\User; +use App\Models\Operator; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -127,7 +127,7 @@ final class SecretVault return $this->row($key)?->updated_at; } - public function put(string $key, string $value, User $by): void + public function put(string $key, string $value, Operator $by): void { $this->assertKnown($key); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 7c3465f..2a4ee9c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -42,23 +42,4 @@ class UserFactory extends Factory 'email_verified_at' => null, ]); } - - /** - * Legacy compatibility: a user created with is_admin => true is an operator, - * so give them the full-control Owner role (roles are seeded by migration). - */ - public function configure(): static - { - return $this->afterCreating(function (User $user) { - if ($user->is_admin && ! $user->hasAnyRole(User::OPERATOR_ROLES)) { - $user->assignRole('Owner'); - } - }); - } - - /** Explicitly give the user an operator role. */ - public function operator(string $role = 'Owner'): static - { - return $this->afterCreating(fn (User $user) => $user->syncRoles([$role])); - } } diff --git a/database/factories/VpnPeerFactory.php b/database/factories/VpnPeerFactory.php index 1cbbfe9..bfa4c21 100644 --- a/database/factories/VpnPeerFactory.php +++ b/database/factories/VpnPeerFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Models\User; +use App\Models\Operator; use App\Models\VpnPeer; use App\Services\Wireguard\Keypair; use Illuminate\Database\Eloquent\Factories\Factory; @@ -16,7 +16,7 @@ class VpnPeerFactory extends Factory return [ 'name' => $this->faker->userName(), 'kind' => VpnPeer::KIND_STAFF, - 'user_id' => User::factory()->operator('Admin'), + 'user_id' => Operator::factory()->role('Admin'), 'public_key' => Keypair::generate()->publicKey, 'allowed_ip' => '10.66.0.'.($octet++), 'enabled' => true, @@ -30,9 +30,9 @@ class VpnPeerFactory extends Factory return $this->state(['kind' => VpnPeer::KIND_HOST, 'user_id' => null]); } - public function ownedBy(User $user): static + public function ownedBy(Operator $operator): static { - return $this->state(['kind' => VpnPeer::KIND_STAFF, 'user_id' => $user->id]); + return $this->state(['kind' => VpnPeer::KIND_STAFF, 'user_id' => $operator->id]); } public function blocked(): static diff --git a/database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php b/database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php new file mode 100644 index 0000000..d4fb62b --- /dev/null +++ b/database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php @@ -0,0 +1,154 @@ +forgetCachedPermissions(); + + DB::table('permissions')->where('guard_name', 'web')->update(['guard_name' => 'operator']); + DB::table('roles')->where('guard_name', 'web')->update(['guard_name' => 'operator']); + + foreach ($this->operatorUserIds() as $userId) { + $this->carryAcross($userId); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + }); + } + + /** Every users row that currently holds a console role. */ + private function operatorUserIds(): array + { + return DB::table('model_has_roles') + ->where('model_type', User::class) + ->pluck('model_id') + ->unique() + ->all(); + } + + private function carryAcross(int $userId): void + { + $row = DB::table('users')->where('id', $userId)->first(); + + if ($row === null) { + return; + } + + // Idempotent: re-running must fix a half-finished move, not fail on the + // unique email. + $operator = Operator::firstOrNew(['email' => $row->email]); + $operator->name = $row->name; + $operator->uuid ??= (string) Str::uuid(); + // operators.password is NOT NULL. The raw update just below is what + // actually carries every sensitive field across, but this first save() + // still has to satisfy the column — with the row's own hash, not a + // throwaway placeholder a crash between the two statements could leave + // behind. + $operator->password ??= $row->password; + $operator->save(); + + // The hash and the two-factor secret are carried, not translated — the + // operator signs in with the same password as before. + DB::table('operators')->where('id', $operator->id)->update([ + 'password' => $row->password, + 'two_factor_secret' => $row->two_factor_secret, + 'two_factor_recovery_codes' => $row->two_factor_recovery_codes, + 'two_factor_confirmed_at' => $row->two_factor_confirmed_at, + 'remember_token' => $row->remember_token, + ]); + + // Re-point the role assignment at the new model. + DB::table('model_has_roles') + ->where('model_type', User::class)->where('model_id', $userId) + ->update(['model_type' => Operator::class, 'model_id' => $operator->id]); + + DB::table('model_has_permissions') + ->where('model_type', User::class)->where('model_id', $userId) + ->update(['model_type' => Operator::class, 'model_id' => $operator->id]); + + $this->removeUserRow($row); + } + + /** + * The users row goes — whoever is in `operators` has no business in `users`. + * + * One exception, and it deletes nothing: a row that is also a customer's + * portal login means the same person is operator AND paying customer. Then + * it is not the row that is wrong but the assumption, and a silent delete + * would take billing data with it. + * + * `users` carries no `customer_id` of its own (checked against the real + * schema) — the link runs the other way, `customers.user_id`, exactly the + * column Customer::ensureUser() writes when it attaches a portal login. That + * is the one place a users row is "also a customer"; seats and orders both + * hang off `customers.id`, never off a user directly, so there is nothing + * further to check on this row once that link is clear. + */ + private function removeUserRow(object $row): void + { + $isPortalLoginForCustomer = DB::table('customers')->where('user_id', $row->id)->exists(); + + if ($isPortalLoginForCustomer) { + throw new RuntimeException( + "Refusing to move [{$row->email}]: this account is both an operator and a customer. " + .'Resolve that by hand before migrating.' + ); + } + + DB::table('users')->where('id', $row->id)->delete(); + } + + /** + * `down()` recreates the `users` rows from `operators`. What it cannot bring + * back is anything that only ever existed on the operator — `last_login_at` + * has no column on `users` and is dropped on the way back, along with + * `disabled_at`. + */ + public function down(): void + { + DB::transaction(function () { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + foreach (DB::table('operators')->get() as $row) { + $userId = DB::table('users')->insertGetId([ + 'name' => $row->name, 'email' => $row->email, 'password' => $row->password, + 'two_factor_secret' => $row->two_factor_secret, + 'two_factor_recovery_codes' => $row->two_factor_recovery_codes, + 'two_factor_confirmed_at' => $row->two_factor_confirmed_at, + 'remember_token' => $row->remember_token, + 'is_admin' => true, + 'created_at' => $row->created_at, 'updated_at' => $row->updated_at, + ]); + + DB::table('model_has_roles') + ->where('model_type', Operator::class)->where('model_id', $row->id) + ->update(['model_type' => User::class, 'model_id' => $userId]); + } + + DB::table('permissions')->where('guard_name', 'operator')->update(['guard_name' => 'web']); + DB::table('roles')->where('guard_name', 'operator')->update(['guard_name' => 'web']); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + }); + } +}; diff --git a/database/migrations/2026_07_29_100001_point_operator_owned_columns_at_operators.php b/database/migrations/2026_07_29_100001_point_operator_owned_columns_at_operators.php new file mode 100644 index 0000000..92a441e --- /dev/null +++ b/database/migrations/2026_07_29_100001_point_operator_owned_columns_at_operators.php @@ -0,0 +1,66 @@ +role('Admin')`, and Vpn::create() only ever writes + * the id of the operator's own account. + * - maintenance_windows.created_by (2026_07_25_140001) — who scheduled it; + * written by Admin\Maintenance from the operator guard. + * - app_secrets.updated_by (2026_07_27_060000) — who last rotated a stored + * credential; written by SecretVault::put(), which now takes an Operator. + * + * None of these is customer-facing. Once the previous migration moves + * operators out of `users`, storing an operator's id in a column still + * constrained to `users` fails at the database, not in application code. + * + * Left for a live server with existing operator-owned rows: their columns + * still hold the OLD users.id, and adding a constraint against `operators` + * below refuses to attach until those rows are re-pointed by hand (matched by + * the same email the previous migration carried operators across on). That + * failure is loud and safe — it blocks the migration rather than silently + * mis-attributing a row — and belongs to the pre-launch dev rehearsal the + * plan already calls for, not to a fresh test database, which never has rows + * in any of these tables at migration time. + */ +return new class extends Migration +{ + /** @var array */ + private const COLUMNS = [ + ['table' => 'vpn_peers', 'column' => 'user_id', 'onDelete' => 'restrict'], + ['table' => 'vpn_peers', 'column' => 'created_by', 'onDelete' => 'null'], + ['table' => 'maintenance_windows', 'column' => 'created_by', 'onDelete' => 'null'], + ['table' => 'app_secrets', 'column' => 'updated_by', 'onDelete' => 'null'], + ]; + + public function up(): void + { + $this->repoint('operators'); + } + + public function down(): void + { + $this->repoint('users'); + } + + private function repoint(string $to): void + { + foreach (self::COLUMNS as ['table' => $table, 'column' => $column, 'onDelete' => $onDelete]) { + Schema::table($table, function (Blueprint $blueprint) use ($column) { + $blueprint->dropForeign([$column]); + }); + + Schema::table($table, function (Blueprint $blueprint) use ($table, $column, $to, $onDelete) { + $foreign = $blueprint->foreign($column)->references('id')->on($to); + $onDelete === 'restrict' ? $foreign->restrictOnDelete() : $foreign->nullOnDelete(); + }); + } + } +}; diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 1766e6c..45dc0e9 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -22,9 +22,7 @@ {{ __('dashboard.no_customer_title') }} {{ __('dashboard.no_customer_hint') }} - @if (auth()->user()->can('console.view')) - {{ __('dashboard.no_customer_cta') }} - @endif + {{-- The console link is gone: a portal account is never an operator (R21). --}} @endif diff --git a/routes/channels.php b/routes/channels.php index 9a9668a..fbc4f3f 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -1,5 +1,6 @@ (bool) $user->can('console.view')); +Broadcast::channel('admin.runs', fn () => (bool) Auth::guard('operator')->user()?->can('console.view')); // A customer's own provisioning feed — bridged from the auth user by email. Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { diff --git a/tests/Feature/Admin/AdminConsoleTest.php b/tests/Feature/Admin/AdminConsoleTest.php index cf442ad..7ab5870 100644 --- a/tests/Feature/Admin/AdminConsoleTest.php +++ b/tests/Feature/Admin/AdminConsoleTest.php @@ -1,5 +1,6 @@ with($adminRoutes); it('forbids non-admin users from the admin console', function (string $route) { - $user = User::factory()->create(['is_admin' => false]); + $user = User::factory()->create(); $this->actingAs($user)->get(route($route))->assertForbidden(); })->with($adminRoutes); it('renders the admin console for admins', function (string $route) { - $admin = User::factory()->create(['is_admin' => true]); + $admin = Operator::factory()->role('Owner')->create(); // Asserted on the console badge and the sign-out control rather than on the // wordmark: the wordmark is split across an element so its second half can // carry the accent, so "CluPilot" is not a contiguous string in the markup. // These two say what the assertion actually meant — the console shell, with // its navigation, rendered for this route. - $this->actingAs($admin)->get(route($route)) + $this->actingAs($admin, 'operator')->get(route($route)) ->assertOk() ->assertSee(__('admin.badge')) ->assertSee(__('admin.nav.overview')) diff --git a/tests/Feature/Admin/AdminHostRestrictionTest.php b/tests/Feature/Admin/AdminHostRestrictionTest.php index d7a3b77..58c1988 100644 --- a/tests/Feature/Admin/AdminHostRestrictionTest.php +++ b/tests/Feature/Admin/AdminHostRestrictionTest.php @@ -7,7 +7,7 @@ use App\Models\User; it('serves the console on any host when ADMIN_HOSTS is empty (default)', function () { config()->set('admin_access.hosts', []); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://app.dev.clupilot.com/admin') ->assertOk(); }); @@ -16,11 +16,11 @@ it('404s the console on a hostname that is not allowed', function () { config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); // A PUBLIC hostname must not even hint that a console exists here. - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://app.dev.clupilot.com/admin') ->assertNotFound(); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://www.dev.clupilot.com/admin/hosts') ->assertNotFound(); }); @@ -28,11 +28,11 @@ it('404s the console on a hostname that is not allowed', function () { it('serves the console on an allowed hostname', function () { config()->set('admin_access.hosts', ['admin.dev.clupilot.com', '10.10.90.185']); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://admin.dev.clupilot.com/admin') ->assertOk(); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://10.10.90.185/admin') ->assertOk(); }); @@ -42,12 +42,12 @@ it('matches configured hostnames case-insensitively', function () { // capitals must still match — otherwise a typo silently locks operators out. config()->set('admin_access.hosts', ['Admin.Dev.CluPilot.COM']); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://admin.dev.clupilot.com/admin') ->assertOk(); // …and the restriction still bites on everything else. - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get('http://www.dev.clupilot.com/admin') ->assertNotFound(); }); @@ -68,7 +68,7 @@ it('blocks admin Livewire actions posted through a public hostname', function () // Render the real page on an ALLOWED host so Livewire records the admin // route's middleware on the snapshot (Livewire::test() skips HTTP routing // and would not, which would make this test pass for the wrong reason). - $html = $this->actingAs($owner) + $html = $this->actingAs($owner, 'operator') ->get('http://admin.dev.clupilot.com/admin/datacenters') ->assertOk() ->getContent(); @@ -87,13 +87,13 @@ it('blocks admin Livewire actions posted through a public hostname', function () ]; // Replayed against a PUBLIC hostname → must not execute. - $this->actingAs($owner) + $this->actingAs($owner, 'operator') ->post('http://www.dev.clupilot.com/livewire/update', $payload()) ->assertNotFound(); // Positive control: the very same payload works on the allowed host, so the // 404 above is the restriction — not a malformed request. - $this->actingAs($owner) + $this->actingAs($owner, 'operator') ->post('http://admin.dev.clupilot.com/livewire/update', $payload()) ->assertOk(); }); diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index d868379..4493326 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -2,6 +2,7 @@ use App\Livewire\Admin\Settings; use App\Models\Customer; +use App\Models\Operator; use App\Models\User; use Livewire\Livewire; @@ -10,53 +11,59 @@ use Livewire\Livewire; it('updates the operator own account', function () { $owner = operator('Owner'); - Livewire::actingAs($owner)->test(Settings::class) + Livewire::actingAs($owner, 'operator')->test(Settings::class) ->set('name', 'New Name')->set('email', 'new@ops.test')->call('saveAccount')->assertHasNoErrors(); expect($owner->fresh()->name)->toBe('New Name')->and($owner->fresh()->email)->toBe('new@ops.test'); }); it('lets an Owner invite staff with a role', function () { - Livewire::actingAs(operator('Owner'))->test(Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') ->call('inviteStaff')->assertHasNoErrors(); - $sam = User::query()->where('email', 'sam@ops.test')->first(); + $sam = Operator::query()->where('email', 'sam@ops.test')->first(); expect($sam)->not->toBeNull()->and($sam->hasRole('Support'))->toBeTrue(); }); it('surfaces a usable temporary password for the invited staff', function () { - $c = Livewire::actingAs(operator('Owner'))->test(Settings::class) + $c = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support') ->call('inviteStaff'); $temp = $c->get('invitedPassword'); expect($temp)->not->toBeNull(); - $ivy = User::query()->where('email', 'ivy@ops.test')->first(); + $ivy = Operator::query()->where('email', 'ivy@ops.test')->first(); expect(Illuminate\Support\Facades\Hash::check($temp, $ivy->password))->toBeTrue(); }); it('forbids a non-Owner from managing staff', function () { - Livewire::actingAs(operator('Admin'))->test(Settings::class) + Livewire::actingAs(operator('Admin'), 'operator')->test(Settings::class) ->set('staffName', 'X')->set('staffEmail', 'x@ops.test')->set('staffRole', 'Support') ->call('inviteStaff')->assertForbidden(); - expect(User::query()->where('email', 'x@ops.test')->exists())->toBeFalse(); + expect(Operator::query()->where('email', 'x@ops.test')->exists())->toBeFalse(); }); it('will not create an operator from a customer email', function () { Customer::factory()->create(['email' => 'dup@ops.test']); - Livewire::actingAs(operator('Owner'))->test(Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->set('staffName', 'Dup')->set('staffEmail', 'dup@ops.test')->set('staffRole', 'Support') ->call('inviteStaff')->assertHasErrors(['staffEmail']); }); it('will not escalate a non-staff user via a tampered id', function () { + // The old risk was one table shared by both groups: a customer's numeric + // id could be handed to setStaffRole() and land on that same row. That is + // structurally closed now — setStaffRole() only ever queries `operators`, + // a table this customer is not in — so the regression to guard against is + // that no operator anywhere picks up the 'Admin' role as a side effect. + $adminsBefore = Operator::role('Admin')->count(); $victim = User::factory()->create(); // a plain (customer) user, no role - Livewire::actingAs(operator('Owner'))->test(Settings::class)->call('setStaffRole', $victim->id, 'Admin'); + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)->call('setStaffRole', $victim->id, 'Admin'); - expect($victim->fresh()->isOperator())->toBeFalse(); + expect(Operator::role('Admin')->count())->toBe($adminsBefore); }); it('protects the last owner and self-role', function () { @@ -64,14 +71,14 @@ it('protects the last owner and self-role', function () { $support = operator('Support'); // Cannot change own role. - Livewire::actingAs($owner)->test(Settings::class)->call('setStaffRole', $owner->id, 'Admin'); + Livewire::actingAs($owner, 'operator')->test(Settings::class)->call('setStaffRole', $owner->id, 'Admin'); expect($owner->fresh()->hasRole('Owner'))->toBeTrue(); // Only one owner → cannot revoke it. - Livewire::actingAs($owner)->test(Settings::class)->call('revokeStaff', $owner->id); + Livewire::actingAs($owner, 'operator')->test(Settings::class)->call('revokeStaff', $owner->id); expect($owner->fresh()->hasRole('Owner'))->toBeTrue(); // Owner can change another operator's role. - Livewire::actingAs($owner)->test(Settings::class)->call('setStaffRole', $support->id, 'Billing'); + Livewire::actingAs($owner, 'operator')->test(Settings::class)->call('setStaffRole', $support->id, 'Billing'); expect($support->fresh()->hasRole('Billing'))->toBeTrue()->and($support->fresh()->hasRole('Support'))->toBeFalse(); }); diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php index 946cbad..7feb730 100644 --- a/tests/Feature/Admin/ConsoleNetworkTest.php +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -27,7 +27,7 @@ beforeEach(function () { }); it('changes nothing until the owner switches it on', function () { - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) ->get('/admin') ->assertOk(); @@ -36,13 +36,13 @@ it('changes nothing until the owner switches it on', function () { it('lets the management VPN in and turns everyone else away', function () { Settings::set('console.network_restricted', true); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '10.66.0.5']) ->get('/admin') ->assertOk(); // 404, never 403: a stranger must not learn a console lives here. - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) ->get('/admin') ->assertNotFound(); @@ -52,13 +52,13 @@ it('lets in an address the owner has listed, without the VPN', function () { Settings::set('console.network_restricted', true); Settings::set('console.allowed_ips', ['203.0.113.0/24']); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) ->get('/admin') ->assertOk(); // A neighbouring range is still nobody. - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '198.51.100.4']) ->get('/admin') ->assertNotFound(); @@ -78,7 +78,7 @@ it('rejects an entry that is not an address or a range', function () { // A typo that silently matches nothing is how someone locks themselves out // while believing they have not. foreach (['nonsense', '203.0.113.', '203.0.113.9/99', ''] as $bad) { - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->set('consoleIp', $bad) ->call('addConsoleIp') @@ -87,7 +87,7 @@ it('rejects an entry that is not an address or a range', function () { expect(Settings::get('console.allowed_ips'))->toBe([]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->set('consoleIp', '203.0.113.0/24') ->call('addConsoleIp') @@ -97,7 +97,7 @@ it('rejects an entry that is not an address or a range', function () { }); it('needs the capability, not merely an operator account', function () { - Livewire::actingAs(operator('Support')) + Livewire::actingAs(operator('Support'), 'operator') ->test(AdminSettings::class) ->call('addConsoleIp') ->assertForbidden(); @@ -179,7 +179,7 @@ it('lets an operator change their own password', function () { // someone opened a shell on the server. $operator = operator('Owner'); - Livewire::actingAs($operator) + Livewire::actingAs($operator, 'operator') ->test(AdminSettings::class) ->set('currentPassword', 'password') ->set('newPassword', 'ein-neues-langes-passwort') @@ -195,7 +195,7 @@ it('refuses to change a password without the current one', function () { // own account with two keystrokes. $operator = operator('Owner'); - Livewire::actingAs($operator) + Livewire::actingAs($operator, 'operator') ->test(AdminSettings::class) ->set('currentPassword', 'falsch') ->set('newPassword', 'ein-neues-langes-passwort') diff --git a/tests/Feature/Admin/ConsoleReportsRealDataTest.php b/tests/Feature/Admin/ConsoleReportsRealDataTest.php index 83da243..1ae3419 100644 --- a/tests/Feature/Admin/ConsoleReportsRealDataTest.php +++ b/tests/Feature/Admin/ConsoleReportsRealDataTest.php @@ -25,7 +25,7 @@ it('reports an empty estate as empty, not as a going concern', function () { // console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a // month. The figures are asserted directly rather than searched for in the // markup — "42" also occurs inside Livewire's own component ids. - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertViewHas('kpis', function (array $kpis) { [$customers, $instances, $hosts, $mrr] = $kpis; @@ -50,7 +50,7 @@ it('counts what is actually there', function () { 'status' => 'active', 'quota_gb' => 250, ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee('1') ->assertSee($host->name); @@ -74,7 +74,7 @@ it('reports host capacity the way placement counts it', function () { expect($host->committedGb())->toBe(600) ->and($host->freeGb())->toBe(800); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) // 600 of the 800 GB placement may actually use, not 250 of 1000. ->assertSee('600 / 800 GB') @@ -100,19 +100,19 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct ]); // 179,00 monthly + 1.200,00 a year ÷ 12 = 179,00 + 100,00 = 279,00 - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(Revenue::class) ->assertSee('279,00') ->assertDontSee('999,00'); }); it('raises a notice only when something is actually wrong', function () { - $clean = Livewire::actingAs(operator('Owner'))->test(Overview::class); + $clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class); $clean->assertSee(__('admin.systems_ok')); ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertSee(__('admin.notice.failed_runs', ['n' => 1])) ->assertDontSee(__('admin.systems_ok')); @@ -159,7 +159,7 @@ it('lists real instances and claims no version it does not record', function () 'subdomain' => 'beispielkunde', 'vmid' => 4711, 'quota_gb' => 500, 'status' => 'active', ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminInstances::class) ->assertSee('beispielkunde') ->assertSee('Beispielkunde GmbH') diff --git a/tests/Feature/Admin/CustomerRevenueTest.php b/tests/Feature/Admin/CustomerRevenueTest.php index 78e1ef9..4b420bd 100644 --- a/tests/Feature/Admin/CustomerRevenueTest.php +++ b/tests/Feature/Admin/CustomerRevenueTest.php @@ -4,6 +4,7 @@ use App\Livewire\Admin\Customers; use App\Models\Customer; use App\Models\Host; use App\Models\Instance; +use App\Models\Operator; use App\Models\PlanFamily; use App\Models\Subscription; use App\Models\User; @@ -33,8 +34,8 @@ function customerOnPlan(string $plan, string $term = 'monthly'): Customer /** The console row for one customer — the factories leave others lying around. */ function mrrOf(Customer $customer): string { - $admin = User::factory()->operator()->create(); - $rows = Livewire::actingAs($admin)->test(Customers::class)->viewData('rows'); + $admin = Operator::factory()->role()->create(); + $rows = Livewire::actingAs($admin, 'operator')->test(Customers::class)->viewData('rows'); return collect($rows)->firstWhere('uuid', $customer->uuid)['mrr']; } @@ -71,8 +72,8 @@ it('keeps customers on a withdrawn plan in the distribution', function () { customerOnPlan('team'); PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); - $admin = User::factory()->operator()->create(); - $chart = Livewire::actingAs($admin)->test(Customers::class)->viewData('plansChart'); + $admin = Operator::factory()->role()->create(); + $chart = Livewire::actingAs($admin, 'operator')->test(Customers::class)->viewData('plansChart'); // They are still running, still paying, and still ours to count. expect($chart['data']['labels'])->toContain(__('billing.plan.team')); diff --git a/tests/Feature/Admin/DatacenterTest.php b/tests/Feature/Admin/DatacenterTest.php index 2dce82f..a6400c9 100644 --- a/tests/Feature/Admin/DatacenterTest.php +++ b/tests/Feature/Admin/DatacenterTest.php @@ -13,11 +13,11 @@ use Livewire\Livewire; it('gates the datacenters page', function () { $this->get(route('admin.datacenters'))->assertRedirect('/login'); $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.datacenters'))->assertForbidden(); - $this->actingAs(admin())->get(route('admin.datacenters'))->assertOk()->assertSee(__('datacenters.title')); + $this->actingAs(admin(), 'operator')->get(route('admin.datacenters'))->assertOk()->assertSee(__('datacenters.title')); }); it('creates a datacenter', function () { - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(Datacenters::class) ->set('code', 'NBG') ->set('name', 'Nürnberg') @@ -31,7 +31,7 @@ it('creates a datacenter', function () { it('rejects a duplicate datacenter code', function () { Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(Datacenters::class) ->set('code', 'fsn')->set('name', 'Falkenstein') ->call('save') @@ -41,7 +41,7 @@ it('rejects a duplicate datacenter code', function () { it('edits a datacenter name and country via the modal', function () { $dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old', 'location' => 'AT']); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) ->set('name', 'Nürnberg') ->set('location', 'DE') @@ -55,7 +55,7 @@ it('edits a datacenter name and country via the modal', function () { it('rejects an invalid country code on edit', function () { $dc = Datacenter::factory()->create(['code' => 'nbg', 'location' => 'DE']); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) ->set('location', 'ZZ')->call('save')->assertHasErrors(['location']); }); @@ -63,7 +63,7 @@ it('rejects an invalid country code on edit', function () { it('deletes an unused datacenter', function () { $dc = Datacenter::factory()->create(['code' => 'nbg']); - Livewire::actingAs(admin())->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid])->call('delete'); + Livewire::actingAs(admin(), 'operator')->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid])->call('delete'); expect(Datacenter::query()->where('uuid', $dc->uuid)->exists())->toBeFalse(); }); @@ -72,7 +72,7 @@ it('refuses to delete a datacenter that still has hosts', function () { $dc = Datacenter::factory()->create(['code' => 'nbg']); Host::factory()->create(['datacenter' => 'nbg']); - Livewire::actingAs(admin())->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid]) + Livewire::actingAs(admin(), 'operator')->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid]) ->assertSet('hostCount', 1) ->call('delete'); @@ -82,11 +82,11 @@ it('refuses to delete a datacenter that still has hosts', function () { it('suspends and reactivates a customer', function () { $customer = \App\Models\Customer::factory()->create(['status' => 'active']); - Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class) + Livewire::actingAs(admin(), 'operator')->test(\App\Livewire\Admin\Customers::class) ->call('toggleSuspend', $customer->uuid); expect($customer->fresh()->status)->toBe('suspended'); - Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class) + Livewire::actingAs(admin(), 'operator')->test(\App\Livewire\Admin\Customers::class) ->call('toggleSuspend', $customer->uuid); expect($customer->fresh()->status)->toBe('active'); }); @@ -94,7 +94,7 @@ it('suspends and reactivates a customer', function () { it('does not let the suspend toggle resurrect a closed customer', function () { $customer = \App\Models\Customer::factory()->create(['status' => 'closed', 'closed_at' => now()]); - Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class) + Livewire::actingAs(admin(), 'operator')->test(\App\Livewire\Admin\Customers::class) ->call('toggleSuspend', $customer->uuid); $customer->refresh(); @@ -104,7 +104,7 @@ it('does not let the suspend toggle resurrect a closed customer', function () { it('toggles a datacenter active flag', function () { $dc = Datacenter::factory()->create(['active' => true]); - Livewire::actingAs(admin())->test(Datacenters::class)->call('toggle', $dc->uuid); + Livewire::actingAs(admin(), 'operator')->test(Datacenters::class)->call('toggle', $dc->uuid); expect($dc->fresh()->active)->toBeFalse(); }); @@ -113,14 +113,14 @@ it('offers only known datacenter codes when adding a host', function () { Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); // unknown code rejected - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-x')->set('datacenter', 'ghost')->set('public_ip', '203.0.113.5')->set('root_password', 'supersecret') ->call('save') ->assertHasErrors(['datacenter']); // known code accepted (defaults to the first active datacenter on mount) - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->assertSet('datacenter', 'fsn'); }); @@ -129,7 +129,7 @@ it('rejects creating a host in an inactive datacenter', function () { Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Datacenter::factory()->create(['code' => 'old', 'active' => false]); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-old')->set('datacenter', 'old')->set('public_ip', '203.0.113.6')->set('root_password', 'supersecret') ->call('save') @@ -142,7 +142,7 @@ it('can switch a location off, and does not evict what already runs there', func $dc = App\Models\Datacenter::factory()->create(['active' => true]); App\Models\Host::factory()->create(['datacenter' => $dc->code]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) ->assertSet('active', true) ->set('active', false) @@ -160,7 +160,7 @@ it('warns about running hosts only when it actually switches a location off', fu $dc = App\Models\Datacenter::factory()->create(['active' => false]); App\Models\Host::factory()->create(['datacenter' => $dc->code]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) ->set('name', 'Neuer Name') ->call('save') diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index e7eb543..d94ebaa 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -17,27 +17,27 @@ use Livewire\Livewire; it('gates the add-host page', function () { $this->get(route('admin.hosts.create'))->assertRedirect('/login'); $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden(); - $this->actingAs(admin())->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title')); + $this->actingAs(admin(), 'operator')->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title')); }); it('gates the host detail page', function () { $host = Host::factory()->create(); $this->get(route('admin.hosts.show', $host))->assertRedirect('/login'); $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden(); - $this->actingAs(admin())->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name); + $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name); }); it('lists real hosts for admins', function () { Host::factory()->active()->create(['name' => 'pve-zzz-1']); - $this->actingAs(admin())->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1'); + $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1'); }); it('creates a host and starts onboarding with an encrypted password', function () { Queue::fake(); \App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-fsn-7') ->set('datacenter', 'fsn') @@ -61,7 +61,7 @@ it('rejects a duplicate public ip', function () { \App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Host::factory()->create(['public_ip' => '203.0.113.99']); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-dup') ->set('datacenter', 'fsn') @@ -72,7 +72,7 @@ it('rejects a duplicate public ip', function () { }); it('validates the add-host form', function () { - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', '') ->set('public_ip', 'not-an-ip') @@ -89,7 +89,7 @@ it('retries a failed run from the detail page', function () { 'started_at' => now()->subHour(), ]); - Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('retry'); + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('retry'); $run->refresh(); expect($run->status)->toBe('running') @@ -104,7 +104,7 @@ it('deactivates the host and queues an async purge', function () { $host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']); ProvisioningRun::factory()->forHost($host)->create(); - Livewire::actingAs(admin()) + Livewire::actingAs(admin(), 'operator') ->test(ConfirmRemoveHost::class, ['uuid' => $host->uuid]) ->call('remove') ->assertRedirect(route('admin.hosts')); @@ -128,7 +128,7 @@ it('renders the live stepper for a running host', function () { $host = Host::factory()->create(['status' => 'onboarding']); ProvisioningRun::factory()->forHost($host)->create(['status' => 'running', 'current_step' => 2]); - $this->actingAs(admin())->get(route('admin.hosts.show', $host)) + $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host)) ->assertOk() ->assertSee(__('hosts.step.prepare_base_system')) ->assertSee(__('hosts.progress')); @@ -140,7 +140,7 @@ it('filters the host list by search and datacenter', function () { Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']); Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']); - Livewire::actingAs(admin())->test(Hosts::class) + Livewire::actingAs(admin(), 'operator')->test(Hosts::class) ->set('search', 'fsn-9') ->assertSee('pve-fsn-9') ->assertDontSee('pve-hel-9') @@ -153,21 +153,21 @@ it('filters the host list by search and datacenter', function () { it('edits host reserve and toggles maintenance', function () { $host = Host::factory()->active()->create(['reserve_pct' => 15]); - Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host]) + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->call('saveReserve', 25) ->call('toggleMaintenance'); $host->refresh(); expect($host->reserve_pct)->toBe(25)->and($host->status)->toBe('disabled'); - Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance'); + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance'); expect($host->fresh()->status)->toBe('active'); }); it('clamps host reserve to a sane range', function () { $host = Host::factory()->active()->create(['reserve_pct' => 15]); - Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500); + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500); expect($host->fresh()->reserve_pct)->toBe(90); }); diff --git a/tests/Feature/Admin/InstanceAdminAccessTest.php b/tests/Feature/Admin/InstanceAdminAccessTest.php index 1490711..7a6e5b1 100644 --- a/tests/Feature/Admin/InstanceAdminAccessTest.php +++ b/tests/Feature/Admin/InstanceAdminAccessTest.php @@ -24,12 +24,12 @@ it('is offered only to operators who may take over an installation', function () $instance = adminAccessInstance(); foreach (['Support', 'Billing', 'Read-only', 'Developer'] as $role) { - Livewire\Livewire::actingAs(operator($role)) + Livewire\Livewire::actingAs(operator($role), 'operator') ->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid]) ->assertForbidden(); } - Livewire\Livewire::actingAs(operator('Owner')) + Livewire\Livewire::actingAs(operator('Owner'), 'operator') ->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid]) ->assertOk(); }); @@ -40,7 +40,7 @@ it('asks for the operator password before touching the instance', function () { $owner = operator('Owner'); $owner->forceFill(['password' => Hash::make('geheim-1234')])->save(); - $modal = Livewire\Livewire::actingAs($owner) + $modal = Livewire\Livewire::actingAs($owner, 'operator') ->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid]) ->set('password', 'falsch') ->call('request') @@ -122,7 +122,7 @@ it('shows the password once and not a second time', function () { $owner = operator('Owner'); $owner->forceFill(['password' => Hash::make('geheim-1234')])->save(); - $modal = Livewire\Livewire::actingAs($owner) + $modal = Livewire\Livewire::actingAs($owner, 'operator') ->test(InstanceAdminAccess::class, ['uuid' => $instance->uuid]) ->set('password', 'geheim-1234') ->call('request'); // sync queue: the job runs immediately diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php index db8e248..5cc4bba 100644 --- a/tests/Feature/Admin/MaintenanceTest.php +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -26,7 +26,7 @@ it('publishes a window and emails affected customers once (idempotent)', functio $host = Host::factory()->active()->create(); $customer = affectedCustomerOn($host); - Livewire::actingAs(operator('Owner'))->test(Maintenance::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Maintenance::class) ->set('title', 'Netz-Wartung') ->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) @@ -45,7 +45,7 @@ it('publishes a window and emails affected customers once (idempotent)', functio ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); // Resending while the first mail is still pending must NOT duplicate. - Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('resend', $window->uuid); + Livewire::actingAs(operator('Owner'), 'operator')->test(Maintenance::class)->call('resend', $window->uuid); Mail::assertQueued(MaintenanceAnnouncementMail::class, 1); }); @@ -60,7 +60,7 @@ it('emails a cancellation to customers who were announced', function () { 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, 'sent_at' => now(), ]); - Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('cancel', $window->uuid); + Livewire::actingAs(operator('Owner'), 'operator')->test(Maintenance::class)->call('cancel', $window->uuid); expect($window->fresh()->state)->toBe('cancelled'); Mail::assertQueued(\App\Mail\MaintenanceCancelledMail::class, 1); @@ -83,7 +83,7 @@ it('scopes the per-instance badge to that instance host', function () { }); it('requires a host to publish', function () { - Livewire::actingAs(operator('Owner'))->test(Maintenance::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Maintenance::class) ->set('title', 'X') ->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) @@ -92,7 +92,7 @@ it('requires a host to publish', function () { }); it('forbids non-privileged roles from managing maintenance', function () { - Livewire::actingAs(operator('Support'))->test(Maintenance::class) + Livewire::actingAs(operator('Support'), 'operator')->test(Maintenance::class) ->set('title', 'X')->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->call('saveDraft')->assertForbidden(); @@ -116,7 +116,7 @@ it('fills the end time from a duration chip', function () { $owner = operator('Owner'); // With a start set, the chip just adds the minutes. - $component = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + $component = Livewire::actingAs($owner, 'operator')->test(App\Livewire\Admin\Maintenance::class) ->set('startsAt', '2026-08-01T22:00') ->call('setDuration', 120); @@ -124,7 +124,7 @@ it('fills the end time from a duration chip', function () { ->and($component->instance()->durationMinutes())->toBe(120); // Without one, it starts at the next half hour rather than doing nothing. - $fresh = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + $fresh = Livewire::actingAs($owner, 'operator')->test(App\Livewire\Admin\Maintenance::class) ->call('setDuration', 60); expect($fresh->get('startsAt'))->not->toBe('') @@ -132,7 +132,7 @@ it('fills the end time from a duration chip', function () { }); it('reports no duration while the times are unusable', function () { - $component = Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Maintenance::class); + $component = Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Maintenance::class); expect($component->instance()->durationMinutes())->toBeNull(); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index e51ed4e..c3d48ba 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -4,10 +4,10 @@ use App\Support\LocalTime; use App\Livewire\Admin\ConfirmDeletePlanDraft; use App\Livewire\Admin\PlanVersions; use App\Livewire\Admin\Plans; +use App\Models\Operator; use App\Models\PlanFamily; use App\Models\PlanVersion; use App\Models\Subscription; -use App\Models\User; use App\Services\Billing\PlanCatalogue; use Livewire\Livewire; @@ -16,9 +16,9 @@ use Livewire\Livewire; * catalogue refuses has to be refused here too, visibly — an admin page that * lets someone try and then throws is worse than one that explains. */ -function owner(): User +function owner(): Operator { - return User::factory()->operator()->create(); + return Operator::factory()->role()->create(); } function teamFamily(): PlanFamily @@ -27,30 +27,30 @@ function teamFamily(): PlanFamily } it('is closed to operators without the capability', function () { - $viewer = User::factory()->operator('Support')->create(); + $viewer = Operator::factory()->role('Support')->create(); // The route middleware lets any operator into the console; the capability // is what decides who can see and change what the business sells. - Livewire::actingAs($viewer)->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs($viewer, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->assertForbidden(); // Including the pages themselves — prices, drafts and unreleased plans are // not something to leave readable to anyone who types the URL. - $this->actingAs($viewer)->get(route('admin.plans'))->assertForbidden(); - $this->actingAs($viewer)->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden(); + $this->actingAs($viewer, 'operator')->get(route('admin.plans'))->assertForbidden(); + $this->actingAs($viewer, 'operator')->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden(); - $this->actingAs(owner())->get(route('admin.plans'))->assertOk(); + $this->actingAs(owner(), 'operator')->get(route('admin.plans'))->assertOk(); }); it('creates a plan line, and refuses a key that is not an identifier', function () { - Livewire::actingAs(owner())->test(Plans::class) + Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->set('key', 'Agentur Paket') ->set('name', 'Agentur') ->set('tier', 5) ->call('save') ->assertHasErrors('key'); - Livewire::actingAs(owner())->test(Plans::class) + Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->set('key', 'agency') ->set('name', 'Agentur') ->set('tier', 5) @@ -69,7 +69,7 @@ it('creates a plan line, and refuses a key that is not an identifier', function it('takes a plan out of the shop without touching anyone\'s contract', function () { $contract = Subscription::factory()->plan('team')->create(); - Livewire::actingAs(owner())->test(Plans::class)->call('toggleSales', teamFamily()->uuid); + Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse() ->and(teamFamily()->sales_enabled)->toBeFalse() @@ -77,12 +77,12 @@ it('takes a plan out of the shop without touching anyone\'s contract', function ->and($contract->fresh()->price_cents)->toBe(17900) ->and($contract->fresh()->status)->toBe('active'); - Livewire::actingAs(owner())->test(Plans::class)->call('toggleSales', teamFamily()->uuid); + Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue(); }); it('drafts a version prefilled from the last one, priced for both terms', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); // Prefilled, so "same plan, new price" is one edit rather than nine. $page->assertSet('ramMb', 8192) @@ -102,7 +102,7 @@ it('drafts a version prefilled from the last one, priced for both terms', functi }); it('refuses a mistyped figure with a message instead of overflowing the column', function () { - Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) // A stray keystroke on the price field: unbounded, this overflows an // unsigned int and the owner gets a 500 with no idea which number // was wrong. @@ -119,14 +119,14 @@ it('does not call a version live when the plan is withdrawn', function () { // The window is still open, but the kill switch overrides it — saying // "on sale" here would contradict what the customer is actually offered. - Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->assertSee(__('plans.badge_withdrawn')) ->assertSee(__('plans.family_withdrawn_note')) ->assertDontSee(__('plans.badge_live')); }); it('refuses a feature key we have no label for', function () { - Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) // The checkboxes only offer real keys, but a request can carry anything. ->set('features', ['managed_updates', 'free_unicorns']) ->call('draft') @@ -136,14 +136,14 @@ it('refuses a feature key we have no label for', function () { it('refuses a performance class we have no label for', function () { // Published, this would be frozen and shown to customers as a raw // translation key for the life of the version. - Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->set('performance', 'enhnaced') ->call('draft') ->assertHasErrors('performance'); }); it('refuses a disk smaller than the storage it has to hold', function () { - Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->set('quotaGb', 500) ->set('diskGb', 100) ->call('draft') @@ -151,7 +151,7 @@ it('refuses a disk smaller than the storage it has to hold', function () { }); it('publishes a draft into a window, and refuses one that overlaps', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->set('monthlyPrice', '199,00')->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); @@ -179,7 +179,7 @@ it('publishes a draft into a window, and refuses one that overlaps', function () }); it('will not publish a window that ends before it starts', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); @@ -192,30 +192,30 @@ it('will not publish a window that ends before it starts', function () { }); it('discards a draft through the modal, and never a published version', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); - Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) + Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) ->call('delete'); expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse(); // The published one cannot even open the dialog — customers are on it. $live = app(PlanCatalogue::class)->currentVersion('team'); - Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $live->uuid]) + Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $live->uuid]) ->assertForbidden(); }); it('does not discard a draft that was published while the dialog was open', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); // The dialog opens on a draft... - $modal = Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]); + $modal = Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]); // ...and someone publishes it before the button is clicked. $live = app(PlanCatalogue::class)->currentVersion('team'); @@ -229,29 +229,29 @@ it('does not discard a draft that was published while the dialog was open', func }); it('authorises the modal itself, not just the page that opens it', function () { - $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); - $viewer = User::factory()->operator('Support')->create(); + $viewer = Operator::factory()->role('Support')->create(); // Modals are reachable without the page's guards. - Livewire::actingAs($viewer)->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) + Livewire::actingAs($viewer, 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) ->assertForbidden(); }); it('lists a withdrawn plan as withdrawn, not as missing', function () { PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); - Livewire::actingAs(owner())->test(Plans::class) + Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->assertSee('team') ->assertSee(__('plans.withdrawn_badge')); }); it('shows the console entry only to those who may use it', function () { - $this->actingAs(owner())->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); + $this->actingAs(owner(), 'operator')->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); - $this->actingAs(User::factory()->operator('Support')->create()) + $this->actingAs(Operator::factory()->role('Support')->create(), 'operator') ->get(route('admin.overview')) ->assertDontSee(__('admin.nav.plans')); }); diff --git a/tests/Feature/Admin/ProvisioningActionsTest.php b/tests/Feature/Admin/ProvisioningActionsTest.php index 8a05474..e9c2a00 100644 --- a/tests/Feature/Admin/ProvisioningActionsTest.php +++ b/tests/Feature/Admin/ProvisioningActionsTest.php @@ -17,7 +17,7 @@ it('retries a failed run from the provisioning console', function () { 'status' => ProvisioningRun::STATUS_FAILED, 'attempt' => 3, 'current_step' => 4, 'error' => 'boom', ]); - Livewire::actingAs(admin())->test(Provisioning::class)->call('retry', $run->uuid); + Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->call('retry', $run->uuid); $run->refresh(); expect($run->status)->toBe(ProvisioningRun::STATUS_RUNNING) @@ -35,7 +35,7 @@ it('ignores retry on a run that is not failed', function () { 'status' => ProvisioningRun::STATUS_RUNNING, ]); - Livewire::actingAs(admin())->test(Provisioning::class)->call('retry', $run->uuid); + Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->call('retry', $run->uuid); expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_RUNNING); Queue::assertNotPushed(AdvanceRunJob::class); diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php new file mode 100644 index 0000000..580d785 --- /dev/null +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -0,0 +1,76 @@ +count())->toBe(0) + ->and(Role::where('guard_name', 'web')->count())->toBe(0) + ->and(Permission::where('guard_name', 'operator')->count())->toBe(17) + ->and(Role::where('guard_name', 'operator')->count())->toBe(6); +}); + +it('keeps Owner holding every permission after the move', function () { + $owner = Role::findByName('Owner', 'operator'); + + expect($owner->permissions()->count())->toBe(Permission::count()); +}); + +it('lets an operator use a console capability and a user not', function () { + $operator = Operator::factory()->role('Owner')->create(); + + expect($operator->can('console.view'))->toBeTrue() + ->and(method_exists(User::factory()->create(), 'hasRole'))->toBeFalse(); +}); + +it('carries an existing operator user across with password and two-factor intact', function () { + // Simulates the pre-migration state on a live server. + $hash = bcrypt('altes-passwort'); + DB::table('users')->insert([ + 'name' => 'Alt', 'email' => 'alt@clupilot.test', 'password' => $hash, + 'two_factor_secret' => 'geheimnis', 'is_admin' => true, + 'created_at' => now(), 'updated_at' => now(), + ]); + $userId = DB::table('users')->where('email', 'alt@clupilot.test')->value('id'); + DB::table('model_has_roles')->insert([ + 'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'), + 'model_type' => User::class, 'model_id' => $userId, + ]); + + // Re-run only the move migration against this hand-built state. + (require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up(); + + $operator = Operator::where('email', 'alt@clupilot.test')->first(); + + expect($operator)->not->toBeNull() + ->and($operator->password)->toBe($hash) + ->and($operator->two_factor_secret)->toBe('geheimnis') + ->and($operator->hasRole('Owner'))->toBeTrue() + // Not mixed: the users row is gone. + ->and(DB::table('users')->where('email', 'alt@clupilot.test')->exists())->toBeFalse(); +}); + +it('refuses to delete a users row that has a customer on it', function () { + // `users` has no `customer_id` column (checked against the real schema) — + // the link runs the other way, `customers.user_id`, exactly what + // Customer::ensureUser() writes when it attaches a portal login. + $customer = Customer::factory()->create(); + $user = User::factory()->create(['email' => 'beides@clupilot.test']); + DB::table('customers')->where('id', $customer->id)->update(['user_id' => $user->id]); + DB::table('model_has_roles')->insert([ + 'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'), + 'model_type' => User::class, 'model_id' => $user->id, + ]); + + // Aborting is the point: silently deleting would take billing data with it. + try { + (require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up(); + $this->fail('The migration should have refused.'); + } catch (RuntimeException $e) { + expect($e->getMessage())->toContain('beides@clupilot.test'); + } +}); diff --git a/tests/Feature/Admin/RbacTest.php b/tests/Feature/Admin/RbacTest.php index a2884e5..b805eb9 100644 --- a/tests/Feature/Admin/RbacTest.php +++ b/tests/Feature/Admin/RbacTest.php @@ -5,44 +5,51 @@ use App\Livewire\Admin\Provisioning; use App\Models\Datacenter; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\Auth; use Livewire\Livewire; it('grants console access to every operator role but not to customers', function () { foreach (['Owner', 'Admin', 'Support', 'Billing', 'Read-only'] as $role) { - $this->actingAs(operator($role))->get(route('admin.overview'))->assertOk(); + $this->actingAs(operator($role), 'operator')->get(route('admin.overview'))->assertOk(); } + // The operator guard itself, not only the default driver, has to be reset: + // actingAs() caches a user directly on the guard instance it targets, and + // switching the default guard back to 'web' does not clear it — the last + // loop iteration's operator would otherwise still answer for + // Auth::guard('operator') and this customer would inherit their access. + Auth::guard('operator')->logout(); $customer = User::factory()->create(); // no role - $this->actingAs($customer)->get(route('admin.overview'))->assertForbidden(); + $this->actingAs($customer, 'web')->get(route('admin.overview'))->assertForbidden(); }); it('denies console access to a role-less account even if is_admin is set', function () { - // RBAC is the boundary — the bare legacy flag must never grant access, so a - // revoked operator (roles removed) cannot reach the console. + // RBAC is the boundary — the bare legacy flag must never grant access. A + // User cannot hold a role at all any more, so this is no longer even a + // question of a role being removed — there is nowhere for one to be. $noRole = User::factory()->create(['is_admin' => true]); - $noRole->syncRoles([]); $this->actingAs($noRole)->get(route('admin.overview'))->assertForbidden(); }); it('denies datacenter management to roles without the capability', function () { foreach (['Support', 'Billing', 'Read-only'] as $role) { - Livewire::actingAs(operator($role))->test(Datacenters::class) + Livewire::actingAs(operator($role), 'operator')->test(Datacenters::class) ->set('code', 'xy')->set('name', 'X')->call('save')->assertForbidden(); } expect(Datacenter::query()->where('code', 'xy')->exists())->toBeFalse(); // Admin has the capability. - Livewire::actingAs(operator('Admin'))->test(Datacenters::class) + Livewire::actingAs(operator('Admin'), 'operator')->test(Datacenters::class) ->set('code', 'nbg2')->set('name', 'Nürnberg')->call('save')->assertHasNoErrors(); expect(Datacenter::query()->where('code', 'nbg2')->exists())->toBeTrue(); }); it('lets Support retry provisioning but not manage datacenters', function () { // Support can retry (capability present) — the action runs (no-op on a missing run). - Livewire::actingAs(operator('Support'))->test(Provisioning::class)->call('retry', 'nonexistent-uuid')->assertOk(); + Livewire::actingAs(operator('Support'), 'operator')->test(Provisioning::class)->call('retry', 'nonexistent-uuid')->assertOk(); // ...but cannot manage datacenters. - Livewire::actingAs(operator('Support'))->test(Datacenters::class)->call('toggle', 'x')->assertForbidden(); + Livewire::actingAs(operator('Support'), 'operator')->test(Datacenters::class)->call('toggle', 'x')->assertForbidden(); }); it('replays the operator and account checks on every Livewire action', function () { diff --git a/tests/Feature/Admin/SecretVaultTest.php b/tests/Feature/Admin/SecretVaultTest.php index 3b4d061..c91cd7b 100644 --- a/tests/Feature/Admin/SecretVaultTest.php +++ b/tests/Feature/Admin/SecretVaultTest.php @@ -1,6 +1,6 @@ get('stripe.secret'))->toBe('sk_env_fallback') ->and($vault->source('stripe.secret'))->toBe('environment'); - $vault->put('stripe.secret', 'sk_live_stored_value', User::factory()->create()); + $vault->put('stripe.secret', 'sk_live_stored_value', Operator::factory()->create()); expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value') ->and($vault->source('stripe.secret'))->toBe('stored'); @@ -34,7 +34,7 @@ it('prefers a stored key over the environment, and says which is in force', func }); it('stores the value encrypted, and never in the clear', function () { - app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', User::factory()->create()); + app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', Operator::factory()->create()); $stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value'); @@ -42,7 +42,7 @@ it('stores the value encrypted, and never in the clear', function () { }); it('shows only an outline, never the value', function () { - app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', User::factory()->create()); + app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', Operator::factory()->create()); $outline = app(SecretVault::class)->outline('stripe.secret'); @@ -52,7 +52,7 @@ it('shows only an outline, never the value', function () { it('refuses a key it does not know', function () { // A form that can set any environment variable is a privilege-escalation // primitive. The registry is the whole point. - expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', User::factory()->create())) + expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', Operator::factory()->create())) ->toThrow(RuntimeException::class); }); @@ -62,7 +62,7 @@ it('refuses to store or read anything without its own key', function () { config()->set('admin_access.secrets_key', ''); expect(app(SecretVault::class)->isUsable())->toBeFalse() - ->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create())) + ->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', Operator::factory()->create())) ->toThrow(RuntimeException::class); }); @@ -79,14 +79,14 @@ it('reports unusable for a malformed key too, not only an empty one', function ( it('fails loudly when a stored value cannot be decrypted, instead of using the old one', function () { // "The key cannot be read" and "there is no key" call for different // actions, and only one of them is fixed by typing it in again. - app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', User::factory()->create()); + app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', Operator::factory()->create()); config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); expect(fn () => app(SecretVault::class)->get('stripe.secret'))->toThrow(RuntimeException::class); }); it('hands the Stripe client the stored key rather than the environment one', function () { - app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', User::factory()->create()); + app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', Operator::factory()->create()); Http::fake(['*' => Http::response(['id' => 'prod_1'], 200)]); @@ -107,7 +107,7 @@ it('accepts the key in the format the documentation tells you to generate', func config()->set('admin_access.secrets_key', $spelling); $vault = app(SecretVault::class); - $vault->put('stripe.secret', 'sk_live_roundtrip', User::factory()->create()); + $vault->put('stripe.secret', 'sk_live_roundtrip', Operator::factory()->create()); expect($vault->get('stripe.secret'))->toBe('sk_live_roundtrip', $spelling); } @@ -116,20 +116,20 @@ it('accepts the key in the format the documentation tells you to generate', func it('refuses a key that is not 32 bytes rather than failing later', function () { config()->set('admin_access.secrets_key', 'viel-zu-kurz'); - expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create())) + expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', Operator::factory()->create())) ->toThrow(RuntimeException::class); }); it('keeps the original creation date when a key is rotated', function () { // The one piece of audit metadata that answers "how long has this been // here?" must survive a rotation. - $user = User::factory()->create(); - app(SecretVault::class)->put('stripe.secret', 'sk_first', $user); + $operator = Operator::factory()->create(); + app(SecretVault::class)->put('stripe.secret', 'sk_first', $operator); $created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'); $this->travel(2)->days(); - app(SecretVault::class)->put('stripe.secret', 'sk_second', $user); + app(SecretVault::class)->put('stripe.secret', 'sk_second', $operator); expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created); }); diff --git a/tests/Feature/Admin/SecretsPageTest.php b/tests/Feature/Admin/SecretsPageTest.php index 9610ddd..bcf7ef6 100644 --- a/tests/Feature/Admin/SecretsPageTest.php +++ b/tests/Feature/Admin/SecretsPageTest.php @@ -28,7 +28,7 @@ it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one' // it. config()->set('admin_access.secrets_key', 'zu-kurz'); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(SecretsPage::class) ->assertSee(__('secrets.no_key')); }); @@ -36,7 +36,7 @@ it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one' it('is not reachable without the capability', function () { // Every operator has console.view. That must not mean "can read the // payment key". - Livewire::actingAs(operator('Admin')) + Livewire::actingAs(operator('Admin'), 'operator') ->test(SecretsPage::class) ->assertForbidden(); }); @@ -45,7 +45,7 @@ it('shows nothing until the password is confirmed', function () { $owner = operator('Owner'); app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner); - $page = Livewire::actingAs($owner)->test(SecretsPage::class); + $page = Livewire::actingAs($owner, 'operator')->test(SecretsPage::class); $page->assertSee(__('secrets.locked_title')) // Not even the outline before unlocking. @@ -58,7 +58,7 @@ it('refuses every action while locked, not merely hiding the buttons', function $owner = operator('Owner'); foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) { - Livewire::actingAs($owner) + Livewire::actingAs($owner, 'operator') ->test(SecretsPage::class) ->call($action, $arg) ->assertForbidden(); @@ -68,7 +68,7 @@ it('refuses every action while locked, not merely hiding the buttons', function it('unlocks with the password, then stores and clears the entered value', function () { $owner = operator('Owner'); - $page = Livewire::actingAs($owner) + $page = Livewire::actingAs($owner, 'operator') ->test(SecretsPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -89,7 +89,7 @@ it('never puts a stored secret into the component snapshot', function () { $owner = operator('Owner'); app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner); - $page = Livewire::actingAs($owner) + $page = Livewire::actingAs($owner, 'operator') ->test(SecretsPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword'); @@ -100,7 +100,7 @@ it('never puts a stored secret into the component snapshot', function () { it('can be locked again without signing out', function () { $owner = operator('Owner'); - Livewire::actingAs($owner) + Livewire::actingAs($owner, 'operator') ->test(SecretsPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 06fa102..6235045 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -28,7 +28,7 @@ it('does not offer to update when the agent has never reported in', function () ->and($state['behind'])->toBeNull() ->and($state['available'])->toBeFalse(); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_no_agent')); }); @@ -40,7 +40,7 @@ it('never reports "up to date" when it simply does not know', function () { // Asserted on the state, not on the markup: "Aktuell" is also a substring // of "Aktuelles Passwort" elsewhere on the same page. - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) ->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false); @@ -53,7 +53,7 @@ it('reports an available update once the agent has looked', function () { expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_available', ['n' => 3])); }); @@ -61,7 +61,7 @@ it('reports an available update once the agent has looked', function () { it('leaves a request the agent can find', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->call('requestUpdate') ->assertHasNoErrors(); @@ -99,7 +99,7 @@ it('forgets a request nobody ever collected', function () { }); it('needs the capability, not merely an operator account', function () { - Livewire::actingAs(operator('Support')) + Livewire::actingAs(operator('Support'), 'operator') ->test(AdminSettings::class) ->call('requestUpdate') ->assertForbidden(); @@ -115,7 +115,7 @@ it('survives a status file that is corrupt', function () { expect($state['agent_seen'])->toBeFalse(); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertOk(); }); @@ -142,7 +142,7 @@ it('says when a queued update will actually start', function () { expect($state['running'])->toBeTrue() ->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String()); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) // Asserted against the operator's wall clock, not against whatever the // view happens to do. The old version of this test built its expected @@ -169,7 +169,7 @@ it('names the step the deployment is on, in the operator’s language', function ]); writePhase('migrate'); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_step', [ 'step' => __('admin_settings.update_phase.migrate'), @@ -221,7 +221,7 @@ it('does not present the last failure’s step as this update’s progress', fun expect($state['running'])->toBeTrue() ->and($state['phase'])->toBeNull(); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_queued', [ 'time' => $state['next_check_at']->local()->format('H:i'), @@ -285,7 +285,7 @@ it('names the step a failed update stopped at', function () { 'exit_code' => 1, ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_failed_at', [ 'step' => __('admin_settings.update_phase.migrate'), @@ -322,7 +322,7 @@ it('stops trusting an agent that has gone quiet', function () { ->and($state['behind'])->toBeNull() ->and($state['available'])->toBeFalse(); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_no_agent')); }); @@ -380,7 +380,7 @@ it('offers the update button even when the last check found nothing', function ( 'behind' => 0, ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_recheck')) ->call('requestUpdate'); @@ -397,7 +397,7 @@ it('still refuses when there is no agent to pick the request up', function () { 'behind' => 0, ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) ->assertDontSee(__('admin_settings.update_recheck')); @@ -412,7 +412,7 @@ it('watches the deployment in a way that survives the restart', function () { 'behind' => 2, ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee('updateWatcher', escape: false); }); @@ -420,7 +420,7 @@ it('watches the deployment in a way that survives the restart', function () { it('answers the watcher without Livewire in the way', function () { writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]); - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at']) diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index 69656de..7e4bc7c 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -30,7 +30,7 @@ it('keeps the VPN entry in the navigation for every operator', function () { // Regression guard: the entry used to hang on a capability that the // capability split deleted, which would have hidden the page from everyone. foreach (['Owner', 'Admin', 'Support', 'Developer', 'Read-only'] as $role) { - $this->actingAs(operator($role))->get(route('admin.overview')) + $this->actingAs(operator($role), 'operator')->get(route('admin.overview')) ->assertOk() ->assertSee(route('admin.vpn')); } @@ -43,13 +43,13 @@ it('shows each operator only their own access unless they may see all', function $foreign = VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']); // Support reaches the page — to find their own access — but sees only it. - $this->actingAs($support)->get(route('admin.vpn')) + $this->actingAs($support, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('Support-Notebook') ->assertDontSee('Fremdes-Notebook'); // Developer may see everything, by capability rather than by ownership. - $this->actingAs(operator('Developer'))->get(route('admin.vpn')) + $this->actingAs(operator('Developer'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('Support-Notebook') ->assertSee('Fremdes-Notebook'); @@ -62,15 +62,15 @@ it('lets only managers create an access', function () { vpnHub(); // The form is not even offered to someone who cannot use it. - $this->actingAs(operator('Support'))->get(route('admin.vpn')) + $this->actingAs(operator('Support'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertDontSee(__('vpn.store_config')); - $this->actingAs(operator('Owner'))->get(route('admin.vpn')) + $this->actingAs(operator('Owner'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee(__('vpn.store_config')); - Livewire::actingAs(operator('Support'))->test(Vpn::class) + Livewire::actingAs(operator('Support'), 'operator')->test(Vpn::class) ->set('name', 'selbst ausgestellt') ->call('create') ->assertForbidden(); @@ -82,7 +82,7 @@ it('creates an access, hands out the config once and pushes it to the hub', func vpnHub(); Queue::fake(); - $component = Livewire::actingAs(operator('Owner')) + $component = Livewire::actingAs(operator('Owner'), 'operator') ->test(Vpn::class) ->set('name', 'Notebook Boban') ->call('create') @@ -115,7 +115,7 @@ it('accepts a peer-supplied public key and then has no private key to show', fun Queue::fake(); $own = Keypair::generate()->publicKey; - $component = Livewire::actingAs(operator('Owner')) + $component = Livewire::actingAs(operator('Owner'), 'operator') ->test(Vpn::class) ->set('name', 'Proxmox fsn1') ->set('publicKey', $own) @@ -130,12 +130,12 @@ it('rejects a malformed key and a key that already has an access', function () { vpnHub(); $existing = VpnPeer::factory()->create(); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'kaputt')->set('publicKey', 'nope') ->call('create') ->assertHasErrors('publicKey'); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'doppelt')->set('publicKey', $existing->public_key) ->call('create') ->assertHasErrors('publicKey'); @@ -148,11 +148,11 @@ it('blocks and unblocks an access without losing the record', function () { Queue::fake(); $peer = VpnPeer::factory()->create(); - Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid); + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled); - Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid); + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeTrue() ->and(VpnPeer::query()->count())->toBe(1); }); @@ -162,7 +162,7 @@ it('removes the peer from the hub before deleting the row', function () { Queue::fake(); $peer = VpnPeer::factory()->create(); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); @@ -175,7 +175,7 @@ it('warns that deleting a host access cuts CluPilot off from that host', functio $host = Host::factory()->active()->create(['name' => 'pve-fsn1']); $peer = VpnPeer::factory()->create(['host_id' => $host->id]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->assertSee('pve-fsn1'); }); @@ -187,7 +187,7 @@ it('stops mutations from a session whose capability was revoked mid-flight', fun $user = operator('Owner'); // Page is open and legitimate… - $component = Livewire::actingAs($user)->test(Vpn::class); + $component = Livewire::actingAs($user, 'operator')->test(Vpn::class); // …then the account is demoted. The already-rendered page must not keep its // powers: every action re-checks, it does not trust the mount. And it must @@ -207,7 +207,7 @@ it('lets an owner switch their own access off and on', function () { $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create(); - Livewire::actingAs($support)->test(Vpn::class)->call('toggle', $peer->uuid); + Livewire::actingAs($support, 'operator')->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled); @@ -272,7 +272,7 @@ it('does not let a failed removal resurrect a revoked access', function () { $peer = VpnPeer::factory()->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); @@ -326,7 +326,7 @@ it('keeps a revoked address and key reserved until the hub confirms', function ( // give two machines the same tunnel IP. expect((new LocalWireguardHub)->allocateIp())->not->toBe('10.66.0.2'); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'wieder da')->set('publicKey', $peer->public_key) ->call('create') ->assertHasErrors('publicKey'); @@ -336,7 +336,7 @@ it('narrows what an open page shows once the account is demoted', function () { vpnHub(); $user = operator('Owner'); VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']); - $component = Livewire::actingAs($user)->test(Vpn::class)->assertSee('Fremdes-Notebook'); + $component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook'); $user->syncRoles(['Support']); app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); @@ -357,7 +357,7 @@ it('holds the same allocation lock the host pipeline uses', function () { expect(Illuminate\Support\Facades\Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); $started = microtime(true); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'parallel') ->call('create') ->assertHasNoErrors(); @@ -421,7 +421,7 @@ it('reports a lost creation race as a validation error, not a 500', function () }; app()->instance(WireguardHub::class, $hub); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'verloren') ->set('publicKey', $key) ->call('create') @@ -453,7 +453,7 @@ it('refuses a public key that already belongs to a host', function () { // The host exists but the scheduled sync has not adopted its peer yet. Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'entführt') ->set('publicKey', $key) ->call('create') @@ -522,7 +522,7 @@ it('stores the config only when asked, and encrypted', function () { Queue::fake(); $owner = operator('Owner'); - Livewire::actingAs($owner)->test(Vpn::class) + Livewire::actingAs($owner, 'operator')->test(Vpn::class) ->set('name', 'Notebook')->set('ownerId', $owner->id) ->set('storeConfig', true) ->call('create') @@ -540,7 +540,7 @@ it('refuses to store when there is no key to store it under', function () { vpnHub(); config()->set('admin_access.vpn_config_key', ''); - Livewire::actingAs(operator('Owner'))->test(Vpn::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'Notebook')->set('storeConfig', true) ->call('create') ->assertHasErrors('storeConfig'); @@ -558,7 +558,7 @@ it('hands a stored config back only to its owner, and only with the password', f ]); // Wrong password: nothing comes back. - $modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) + $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'falsch') ->call('reveal') ->assertHasErrors('password'); @@ -586,19 +586,19 @@ it('never hands someone else the private key, not even an Owner', function () { ->and($other->can('downloadConfig', $peer))->toBeFalse(); // The page shows them the access without offering its config… - $this->actingAs($other)->get(route('admin.vpn')) + $this->actingAs($other, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee($peer->name) ->assertDontSee('admin.vpn-config-access'); // …and going straight at the modal is refused, not merely hidden. - Livewire::actingAs($other) + Livewire::actingAs($other, 'operator') ->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->assertForbidden(); } // The owner still gets the button. - $this->actingAs($support)->get(route('admin.vpn')) + $this->actingAs($support, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('admin.vpn-config-access'); }); @@ -610,7 +610,7 @@ it('locks out password guessing', function () { 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); - $modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); + $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); foreach (range(1, 5) as $attempt) { $modal->set('password', 'falsch'.$attempt)->call('reveal')->assertHasErrors('password'); } @@ -628,7 +628,7 @@ it('destroys the stored config when the access is revoked', function () { 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); @@ -646,7 +646,7 @@ it('takes the tunnel away when a staff member is revoked', function () { 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); - Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('revokeStaff', $support->id); expect($support->fresh()->isOperator())->toBeFalse() @@ -663,7 +663,7 @@ it('removes a revoked colleague from the hub only after the rows are committed', // Sync queue: the job runs the moment it is dispatched, so if that happened // inside the transaction it would still see a live peer and leave it up. - Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('revokeStaff', $support->id); expect($hub->peerIps())->toBe([]) @@ -679,14 +679,14 @@ it('stops serving a revealed config once the access is revoked', function () { 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); - $modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) + $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'geheim-1234') ->call('reveal'); $token = $modal->get('token'); expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); // Revoked while the modal is still open on someone's screen. - Livewire::actingAs(operator('Owner')) + Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); @@ -700,7 +700,7 @@ it('never leaves the previous config on screen after another access is created', Queue::fake(); $owner = operator('Owner'); - $component = Livewire::actingAs($owner)->test(Vpn::class) + $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class) ->set('name', 'Erster')->set('ownerId', $owner->id) ->call('create'); $firstToken = $component->get('configToken'); @@ -722,7 +722,7 @@ it('does not keep polling while a private config is on screen', function () { Queue::fake(); $owner = operator('Owner'); - $component = Livewire::actingAs($owner)->test(Vpn::class)->assertSee('wire:poll', false); + $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->assertSee('wire:poll', false); $component->set('name', 'Notebook')->set('ownerId', $owner->id)->call('create'); @@ -736,10 +736,20 @@ it('does not keep polling while a private config is on screen', function () { it('will not issue an access to someone who is no longer staff', function () { vpnHub(); + $owner = operator('Owner'); $former = App\Models\User::factory()->create(); // no operator role - Livewire::actingAs(operator('Owner'))->test(Vpn::class) - ->set('name', 'Ex-Kollege')->set('ownerId', $former->id) + // Not $former->id directly: operators and users are independent id + // sequences, and the in-memory test database restarts both near 1 after + // every test's rollback, so a users id colliding with a real operator's is + // routine here, not a one-in-a-billion accident. Past the highest id either + // sequence has handed out this test is an id no operator can hold — proving + // the same thing ownerId=$former->id would, without depending on the two + // sequences staying apart by luck. + $ownerId = max($former->id, $owner->id) + 1; + + Livewire::actingAs($owner, 'operator')->test(Vpn::class) + ->set('name', 'Ex-Kollege')->set('ownerId', $ownerId) ->call('create') ->assertHasErrors('ownerId'); @@ -764,7 +774,7 @@ it('closes the owner doors as soon as the roles are gone', function () { ->and($support->can('delete', $peer))->toBeFalse() ->and($support->can('block', $peer))->toBeFalse(); - Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden(); + Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden(); }); it('re-issues a key for an access whose config was never stored', function () { @@ -774,7 +784,7 @@ it('re-issues a key for an access whose config was never stored', function () { $peer = VpnPeer::factory()->ownedBy($owner)->create(); $oldKey = $peer->public_key; - $component = Livewire::actingAs($owner)->test(Vpn::class)->call('reissue', $peer->uuid); + $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // A new key, and the config handed over once — the only honest answer when // the private half of the old one exists nowhere. @@ -798,7 +808,7 @@ it('keeps a stored config in step when the key is re-issued', function () { 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), ]); - Livewire::actingAs($owner)->test(Vpn::class)->call('reissue', $peer->uuid); + Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Otherwise the owner would download a configuration whose key the hub no // longer accepts. @@ -814,7 +824,7 @@ it('does not re-issue a host peer from the VPN page', function () { $peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]); $oldKey = $peer->public_key; - Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('reissue', $peer->uuid); + Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Its key belongs to the host's own wg0 — swapping it here would cut the // machine off with nothing to repair it. @@ -832,7 +842,7 @@ it('revokes the replaced key even after a sync adopted it', function () { // Queue faked on purpose: the window only exists while the removal is still // pending, which a synchronous queue would close instantly. Queue::fake(); - Livewire::actingAs($owner)->test(Vpn::class)->call('reissue', $peer->uuid); + Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // A sync lands before the removal runs. It must neither adopt the old key // (its address belongs to the live access) nor blow up on the unique index, @@ -875,7 +885,7 @@ it('re-issues even when the config vault is unavailable', function () { // The very situation the console tells people to fix by re-issuing. config()->set('admin_access.vpn_config_key', ''); - Livewire::actingAs($owner)->test(Vpn::class)->call('reissue', $peer->uuid); + Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); expect($peer->fresh()->config_secret)->toBeNull(); // shown once instead }); diff --git a/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php b/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php index 6a48186..18b40ce 100644 --- a/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php +++ b/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php @@ -16,15 +16,19 @@ beforeEach(function () { config()->set('admin_access.exclusive', true); }); -it('sends an operator signing in on the console host to the console', function () { +it('refuses an operator at the shared portal login — the account is not in that table any more', function () { + // Before the identity move this landed in the console (that is what this + // file used to prove). Now there is no operator row Fortify's web-guard + // attempt could ever match, on any host — refusing it here is the correct + // behaviour, not a gap Task 5's dedicated console login has yet to close. $operator = operator('Owner'); $this->post('http://admin.example.test/login', [ 'email' => $operator->email, 'password' => 'password', - ])->assertRedirect('/'); + ])->assertSessionHasErrors('email'); - expect(auth()->check())->toBeTrue(); + expect(auth()->check())->toBeFalse(); }); it('sends a customer signing in on the portal host to the portal', function () { @@ -68,15 +72,21 @@ it('makes the same decision after a two-factor challenge', function () { ->and($response->getTargetUrl())->not->toContain('/dashboard'); }); -it('still answers JSON to a JSON client', function () { - // Replacing Fortify's JSON reply with a redirect breaks every caller that - // is not a browser. +it('gives a JSON client the same refusal as a browser gets', function () { + // bootstrap/app.php restricts JSON exception rendering to api/* paths, so + // a failed /login attempt redirects with session errors regardless of the + // caller's Accept header — there is no separate JSON error shape here for + // an operator's address to diverge from. What still matters is that a + // JSON-flavoured request does not somehow slip past that and end up + // authenticated. $operator = operator('Owner'); $this->postJson('http://admin.example.test/login', [ 'email' => $operator->email, 'password' => 'password', - ])->assertOk()->assertJson(['two_factor' => false]); + ])->assertRedirect(); + + expect(auth()->check())->toBeFalse(); }); it('does not let a JSON client keep a session a browser would lose', function () { diff --git a/tests/Feature/Auth/RegisterTest.php b/tests/Feature/Auth/RegisterTest.php index 97afdfc..5b06ab4 100644 --- a/tests/Feature/Auth/RegisterTest.php +++ b/tests/Feature/Auth/RegisterTest.php @@ -20,7 +20,9 @@ it('registers a new account and signs in', function () { ])->assertRedirect(); $user = User::query()->where('email', 'new@signup.test')->first(); - expect($user)->not->toBeNull()->and($user->isOperator())->toBeFalse(); + // A registered user is never an operator: the two no longer share a table + // or a role system to check "isOperator" against at all. + expect($user)->not->toBeNull(); $this->assertAuthenticatedAs($user); // A linked customer is created so the portal has something to work with. diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php index 3ec8758..1821057 100644 --- a/tests/Feature/ImpersonationTest.php +++ b/tests/Feature/ImpersonationTest.php @@ -1,6 +1,7 @@ create(['email' => 'c@imp.test', 'name' => 'Imp Kunde']); - $this->actingAs($admin) + $this->actingAs($admin, 'operator') ->post(route('admin.impersonate', $customer->uuid)) ->assertRedirect(route('dashboard')); @@ -33,10 +34,17 @@ it('reuses an existing portal user with the same email', function () { expect($customer->fresh()->user_id)->toBe($existing->id); }); -it('refuses to link an admin account as a portal login', function () { - $adminUser = User::factory()->create(['email' => 'ops@imp.test', 'is_admin' => true]); +it('refuses to auto-link an existing portal account while an operator is signed in', function () { + // assertNotAdmin() used to check whether the row it found WAS an operator + // — structurally impossible now, since operators are not rows in this + // table at all. What is left to guard is the moment rather than the row: + // ensureUser() must not silently attach a `users` row to a customer while + // the caller is an operator. + User::factory()->create(['email' => 'ops@imp.test']); $customer = Customer::factory()->create(['email' => 'ops@imp.test']); + $this->actingAs(Operator::factory()->role('Owner')->create(), 'operator'); + expect(fn () => $customer->ensureUser())->toThrow(RuntimeException::class); expect($customer->fresh()->user_id)->toBeNull(); }); diff --git a/tests/Feature/Mail/MailSettingsPageTest.php b/tests/Feature/Mail/MailSettingsPageTest.php index 48b5db2..401d485 100644 --- a/tests/Feature/Mail/MailSettingsPageTest.php +++ b/tests/Feature/Mail/MailSettingsPageTest.php @@ -3,7 +3,7 @@ use App\Livewire\Admin\Mail as MailPage; use App\Livewire\EditMailbox; use App\Models\Mailbox; -use App\Models\User; +use App\Models\Operator; use App\Services\Mail\MailPurpose; use App\Support\Settings; use Livewire\Livewire; @@ -21,34 +21,34 @@ beforeEach(function () { }); it('is closed to an operator without mail.manage', function () { - $user = User::factory()->operator('Read-only')->create(); + $user = Operator::factory()->role('Read-only')->create(['password' => 'password']); - Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); + Livewire::actingAs($user, 'operator')->test(MailPage::class)->assertForbidden(); }); it('opens for an operator who has it', function () { - $user = User::factory()->operator('Owner')->create(); + $user = Operator::factory()->role('Owner')->create(['password' => 'password']); - Livewire::actingAs($user)->test(MailPage::class)->assertOk()->assertSet('usable', true); + Livewire::actingAs($user, 'operator')->test(MailPage::class)->assertOk()->assertSet('usable', true); }); it('grants mail.manage to Admin and Support as well as Owner', function () { // The migration hands the capability to three roles, not one. Nothing // above proves the other two actually received it — an Owner-only test // would stay green even if the migration's role loop silently lost a name. - expect(User::factory()->operator('Admin')->create()->can('mail.manage'))->toBeTrue() - ->and(User::factory()->operator('Support')->create()->can('mail.manage'))->toBeTrue(); + expect(Operator::factory()->role('Admin')->create(['password' => 'password'])->can('mail.manage'))->toBeTrue() + ->and(Operator::factory()->role('Support')->create(['password' => 'password'])->can('mail.manage'))->toBeTrue(); }); it('is not opened by secrets.manage alone — that is the point of splitting them', function () { // Billing holds secrets-adjacent capabilities but has no business changing // the support sender, and vice versa. If this ever passes, the two // capabilities have quietly been merged again. - $user = User::factory()->operator('Billing')->create(); + $user = Operator::factory()->role('Billing')->create(['password' => 'password']); expect($user->can('mail.manage'))->toBeFalse(); - Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); + Livewire::actingAs($user, 'operator')->test(MailPage::class)->assertForbidden(); }); it('edits a mailbox in a modal, never in the row (R20)', function () { @@ -59,7 +59,7 @@ it('edits a mailbox in a modal, never in the row (R20)', function () { expect($blade)->not->toMatch('/]*>(?:(?!<\/td>).)*and($blade)->toContain('openModal'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); @@ -70,7 +70,7 @@ it('edits a mailbox in a modal, never in the row (R20)', function () { it('never hands a stored password back to the browser', function () { $box = Mailbox::factory()->create(['password' => 'streng-geheim']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSet('password', '') ->assertDontSee('streng-geheim'); @@ -79,7 +79,7 @@ it('never hands a stored password back to the browser', function () { it('keeps the old password when the field is left empty', function () { $box = Mailbox::factory()->create(['password' => 'bleibt']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); @@ -90,7 +90,7 @@ it('keeps the old password when the field is left empty', function () { it('shows the SECRETS_KEY warning on the page', function () { config()->set('admin_access.secrets_key', ''); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertOk() ->assertSet('usable', false) @@ -105,7 +105,7 @@ it('shows the same SECRETS_KEY warning for a malformed key, not only a missing o // not — the two tests below are what happened next. config()->set('admin_access.secrets_key', 'zu-kurz'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertOk() ->assertSet('usable', false) @@ -121,13 +121,13 @@ it('fails the password save with a form error, not an exception, when SECRETS_KE // password-writing tests above run under this file's valid-key beforeEach, // so none of them would have caught it. $box = Mailbox::factory()->create(['key' => 'support']); - $owner = User::factory()->operator('Owner')->create(); + $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); // Confirmed BEFORE the key is cleared: this test is about the SECRETS_KEY // guard specifically, not about the password-confirmation gate above it — // conflating the two would leave it unclear which one actually produced // the error. - $page = Livewire::actingAs($owner) + $page = Livewire::actingAs($owner, 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword'); @@ -148,9 +148,9 @@ it('fails the password save with a form error, not an exception, for a malformed // guard did not fire here: isUsable() answered true for a malformed key, // and $box->password = $this->password went ahead and threw uncaught. $box = Mailbox::factory()->create(['key' => 'support']); - $owner = User::factory()->operator('Owner')->create(); + $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); - $page = Livewire::actingAs($owner) + $page = Livewire::actingAs($owner, 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword'); @@ -166,7 +166,7 @@ it('refuses to leave the system purpose empty, because it is the fallback', func Mailbox::factory()->create(['key' => 'no-reply']); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', '') ->call('savePurposes') @@ -185,7 +185,7 @@ it('refuses to deactivate the mailbox that "system" currently points at', functi $box = Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('active', false) ->call('save') @@ -204,7 +204,7 @@ it('still allows deactivating a mailbox that is not behind "system"', function ( $otherBox = Mailbox::factory()->create(['key' => 'support', 'active' => true]); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $otherBox->uuid]) ->set('active', false) ->call('save') @@ -222,7 +222,7 @@ it('still allows deactivating a mailbox that is not behind "system"', function ( it('lists every mailbox on the page', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertSee('support@clupilot.com') ->assertSee('support'); @@ -232,13 +232,13 @@ it('shows the currently configured mailbox for each purpose', function () { Mailbox::factory()->create(['key' => 'support']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertSet('purposes.support', 'support'); }); it('saves the mail server settings', function () { - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -254,7 +254,7 @@ it('saves the mail server settings', function () { }); it('rejects an empty mail server host', function () { - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -280,7 +280,7 @@ it("clears every mailbox's last_verified_at when the server host changes", funct $a = Mailbox::factory()->create(['last_verified_at' => now()]); $b = Mailbox::factory()->create(['last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -299,7 +299,7 @@ it("clears every mailbox's last_verified_at when the server port changes", funct $box = Mailbox::factory()->create(['last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -317,7 +317,7 @@ it("clears every mailbox's last_verified_at when the server encryption changes", $box = Mailbox::factory()->create(['last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -340,7 +340,7 @@ it('keeps last_verified_at when the server card is saved with no actual change', $box = Mailbox::factory()->create(['last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -363,7 +363,7 @@ it('shows "not yet verified" instead of a blank cell once the server change clea Mailbox::factory()->create(['key' => 'support', 'last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -379,7 +379,7 @@ it('refuses to change the mail server without a recently confirmed password, cap // session still gets blocked for want of a recent confirmation. $before = Settings::get('mail.host'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('host', 'evil-relay.example.com') ->set('port', 2525) @@ -402,7 +402,7 @@ it('does not require a confirmed password to save the purpose mapping', function // the tests that merely never happen to call confirmPassword(). Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', 'support') ->set('purposes.support', 'support') @@ -416,7 +416,7 @@ it('saves the purpose-to-mailbox mapping', function () { Mailbox::factory()->create(['key' => 'support']); Mailbox::factory()->create(['key' => 'no-reply']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', 'no-reply') ->set('purposes.support', 'support') @@ -435,7 +435,7 @@ it('saves the purpose-to-mailbox mapping', function () { it('refuses to map system to a mailbox key that names no mailbox at all', function () { Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', 'a-deleted-or-tampered-key') ->call('savePurposes') @@ -451,7 +451,7 @@ it('refuses to map system to a mailbox that exists but is not active', function // nothing behind it — not just system's own mail. Mailbox::factory()->create(['key' => 'no-reply', 'active' => false]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.system', 'no-reply') ->call('savePurposes') @@ -464,7 +464,7 @@ it('refuses to map a non-system purpose to a mailbox key that names no mailbox a Mailbox::factory()->create(['key' => 'no-reply']); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.support', 'a-deleted-or-tampered-key') ->call('savePurposes') @@ -482,7 +482,7 @@ it('still allows mapping a non-system purpose to a mailbox that exists but is in Mailbox::factory()->create(['key' => 'support', 'active' => false]); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.support', 'support') ->call('savePurposes') @@ -500,7 +500,7 @@ it('still allows leaving a non-system purpose blank, even after it once had a ma Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('purposes.support', '') ->call('savePurposes') @@ -516,17 +516,17 @@ it('re-checks mail.manage on every page action, not only at mount', function () // once. Swap the authenticated user between mount and the action — same // shape as SecretsPageTest's "refuses every action while locked", applied // to the capability gate instead of the password gate. - $owner = User::factory()->operator('Owner')->create(); - $billing = User::factory()->operator('Billing')->create(); + $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); + $billing = Operator::factory()->role('Billing')->create(['password' => 'password']); // A fresh mount per action, like SecretsPageTest's equivalent: a 403 // response carries no usable Livewire snapshot, so a second ->call() on // the same already-forbidden instance fails on the snapshot itself // rather than on the authorization this test means to check. foreach (['saveServer', 'savePurposes'] as $action) { - $page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk(); + $page = Livewire::actingAs($owner, 'operator')->test(MailPage::class)->assertOk(); - Livewire::actingAs($billing); + Livewire::actingAs($billing, 'operator'); $page->call($action)->assertForbidden(); } @@ -537,12 +537,12 @@ it('re-checks mail.manage on modal save, not only at mount', function () { // trusting the browser (R20), but that is a different guarantee from // save() re-authorising instead of trusting a mount that already happened. $box = Mailbox::factory()->create(['key' => 'support']); - $owner = User::factory()->operator('Owner')->create(); - $billing = User::factory()->operator('Billing')->create(); + $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); + $billing = Operator::factory()->role('Billing')->create(['password' => 'password']); - $modal = Livewire::actingAs($owner)->test(EditMailbox::class, ['uuid' => $box->uuid])->assertOk(); + $modal = Livewire::actingAs($owner, 'operator')->test(EditMailbox::class, ['uuid' => $box->uuid])->assertOk(); - Livewire::actingAs($billing); + Livewire::actingAs($billing, 'operator'); $modal->set('address', 'neu@clupilot.com')->call('save')->assertForbidden(); }); @@ -550,7 +550,7 @@ it('re-checks mail.manage on modal save, not only at mount', function () { it('refuses to edit a mailbox without mail.manage, even though the modal bypasses the page route', function () { $box = Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Billing')->create()) + Livewire::actingAs(Operator::factory()->role('Billing')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertForbidden(); }); @@ -563,7 +563,7 @@ it('updates the password when a new one is entered', function () { // second gate (below) is satisfied. $box = Mailbox::factory()->create(['password' => 'alt']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -576,7 +576,7 @@ it('updates the password when a new one is entered', function () { it('clears last_verified_at when the password changes, since it no longer proves anything', function () { $box = Mailbox::factory()->create(['password' => 'alt', 'last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('confirmablePassword', 'password') ->call('confirmPassword') @@ -597,7 +597,7 @@ it('clears last_verified_at when the address changes, even though the password s 'address' => 'alt@clupilot.com', 'username' => null, 'last_verified_at' => now(), ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); @@ -610,7 +610,7 @@ it('clears last_verified_at when an explicit username changes, even though the p 'username' => 'alt-smtp-user', 'last_verified_at' => now(), ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('username', 'neu-smtp-user') ->call('save'); @@ -627,7 +627,7 @@ it('clears last_verified_at when the username is cleared, since smtpUsername() t 'address' => 'bleibt@clupilot.com', 'username' => 'alt-smtp-user', 'last_verified_at' => now(), ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('username', '') ->call('save'); @@ -645,7 +645,7 @@ it('keeps last_verified_at when neither the address nor the username actually ch 'address' => 'bleibt@clupilot.com', 'username' => 'bleibt-auch', 'last_verified_at' => now(), ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('displayName', 'Neuer Anzeigename') ->call('save'); @@ -659,7 +659,7 @@ it('refuses to set a new mailbox password without a recently confirmed password' // mailbox's outgoing SMTP password comes from. $box = Mailbox::factory()->create(['password' => 'alt']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('password', 'neu-und-sicher') ->call('save') @@ -677,7 +677,7 @@ it('does not require a confirmed password to save address, display name, usernam // reachable (no 403), rather than leaving the claim implicit. $box = Mailbox::factory()->create(['address' => 'alt@clupilot.com']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->set('displayName', 'Neu-Team') @@ -691,7 +691,7 @@ it('does not require a confirmed password to save address, display name, usernam it('saves display name, username, no-reply and active together', function () { $box = Mailbox::factory()->create(['no_reply' => false, 'active' => true]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('displayName', 'Support-Team') ->set('username', 'smtp-user') @@ -717,7 +717,7 @@ it('pre-fills the form from the existing record', function () { 'authenticates' => false, ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSet('address', 'support@clupilot.com') ->assertSet('displayName', 'Support-Team') @@ -735,7 +735,7 @@ it('renders an authenticates checkbox in the modal, wired to the property', func // operator can actually reach — R20's own point. $box = Mailbox::factory()->create(['authenticates' => true]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSeeHtml('wire:model.live="authenticates"') ->assertSee(__('mail_settings.authenticates')); @@ -744,7 +744,7 @@ it('renders an authenticates checkbox in the modal, wired to the property', func it('saves authenticates together with the other fields', function () { $box = Mailbox::factory()->create(['authenticates' => true]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('authenticates', false) ->call('save'); @@ -759,7 +759,7 @@ it('clears last_verified_at when authenticates changes, even though the password // at all has flipped. $box = Mailbox::factory()->create(['authenticates' => true, 'last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('authenticates', false) ->call('save'); @@ -773,7 +773,7 @@ it('does not clear last_verified_at when authenticates is saved unchanged', func // username control test just above this block. $box = Mailbox::factory()->create(['authenticates' => true, 'last_verified_at' => now()]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('displayName', 'Neuer Anzeigename') ->call('save'); @@ -797,7 +797,7 @@ it('does not report "no password stored" when testing a mailbox that does not au 'username' => null, 'password' => null, 'authenticates' => false, ]); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -813,12 +813,12 @@ it('does not report "no password stored" when testing a mailbox that does not au it('refuses to run a mailbox test without mail.manage, re-checked independently of mount', function () { $box = Mailbox::factory()->create(['key' => 'support']); - $owner = User::factory()->operator('Owner')->create(); - $billing = User::factory()->operator('Billing')->create(); + $owner = Operator::factory()->role('Owner')->create(['password' => 'password']); + $billing = Operator::factory()->role('Billing')->create(['password' => 'password']); - $page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk(); + $page = Livewire::actingAs($owner, 'operator')->test(MailPage::class)->assertOk(); - Livewire::actingAs($billing); + Livewire::actingAs($billing, 'operator'); $page->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -828,7 +828,7 @@ it('refuses to run a mailbox test without mail.manage, re-checked independently it('requires a recipient before sending a test', function () { $box = Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', '') ->call('test', $box->uuid) @@ -838,7 +838,7 @@ it('requires a recipient before sending a test', function () { it('requires the recipient to actually be an email address', function () { $box = Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'not-an-email') ->call('test', $box->uuid) @@ -854,7 +854,7 @@ it('runs the real tester against the chosen mailbox and records the result', fun Settings::set('mail.port', 1); $box = Mailbox::factory()->create(['key' => 'support']); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -875,7 +875,7 @@ it('shows the result against the mailbox it actually tested', function () { $box = Mailbox::factory()->create(['key' => 'support']); Mailbox::factory()->create(['key' => 'billing']); // a second row: proves it's not just "any key" - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -893,7 +893,7 @@ it('does not crash the test button when SECRETS_KEY is missing, and says so inst $box = Mailbox::factory()->create(['key' => 'support']); config()->set('admin_access.secrets_key', ''); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -909,7 +909,7 @@ it('does not crash the test button for a malformed SECRETS_KEY either, and says $box = Mailbox::factory()->create(['key' => 'support']); config()->set('admin_access.secrets_key', 'zu-kurz'); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -930,7 +930,7 @@ it('does not crash the test button when the stored password was encrypted under $box = Mailbox::factory()->create(['key' => 'support', 'password' => 'geheim']); config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) @@ -944,7 +944,7 @@ it('offers a per-row test-send button, not only edit', function () { }); it('tells the operator the test send really goes out, even though ordinary mail only logs here', function () { - Livewire::actingAs(User::factory()->operator('Owner')->create()) + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) ->assertSee(__('mail_settings.test_hint')); }); diff --git a/tests/Feature/OperatorInPortalTest.php b/tests/Feature/OperatorInPortalTest.php deleted file mode 100644 index 485de73..0000000 --- a/tests/Feature/OperatorInPortalTest.php +++ /dev/null @@ -1,63 +0,0 @@ -operator('Owner')->create(); -} - -it('warns instead of silently ignoring a purchase without a customer', function () { - Livewire::actingAs(operatorUser())->test(Billing::class) - ->call('purchase', 'storage') - ->assertDispatched('notify'); - - expect(Order::query()->count())->toBe(0); -}); - -it('warns instead of silently ignoring a profile save without a customer', function () { - Livewire::actingAs(operatorUser())->test(Settings::class) - ->set('companyName', 'Nope GmbH') - ->call('saveProfile') - ->assertDispatched('notify'); -}); - -it('warns instead of silently ignoring a seat invite without a customer', function () { - Livewire::actingAs(operatorUser())->test(Users::class) - ->set('inviteEmail', 'x@no-customer.test') - ->call('invite') - ->assertDispatched('notify'); - - expect(\App\Models\Seat::query()->count())->toBe(0); -}); - -it('shows an error in the cancel-package modal without a customer', function () { - Livewire::actingAs(operatorUser())->test(ConfirmCancelPackage::class) - ->set('confirmName', 'whatever') - ->call('cancelPackage') - ->assertHasErrors(['confirmName']); -}); - -it('shows an error in the close-account modal without a customer', function () { - Livewire::actingAs(operatorUser())->test(ConfirmCloseAccount::class) - ->set('confirmWord', __('settings.close_keyword')) - ->call('closeAccount') - ->assertHasErrors(['confirmWord']); -}); - -it('renders the portal notice for an operator account', function () { - $this->actingAs(operatorUser())->get(route('dashboard')) - ->assertOk() - ->assertSee(__('dashboard.no_customer_title')); -}); diff --git a/tests/Feature/Provisioning/LiveProgressTest.php b/tests/Feature/Provisioning/LiveProgressTest.php index e1cf0f6..537881d 100644 --- a/tests/Feature/Provisioning/LiveProgressTest.php +++ b/tests/Feature/Provisioning/LiveProgressTest.php @@ -2,6 +2,7 @@ use App\Livewire\CustomerProvisioning; use App\Models\Customer; +use App\Models\Operator; use App\Models\Order; use App\Models\ProvisioningRun; use App\Models\User; @@ -14,7 +15,7 @@ it('shows real provisioning runs to admins', function () { 'pipeline' => 'customer', 'status' => 'running', 'current_step' => 7, ]); - $this->actingAs(User::factory()->create(['is_admin' => true])) + $this->actingAs(Operator::factory()->role('Owner')->create(), 'operator') ->get(route('admin.provisioning')) ->assertOk() ->assertSee($order->customer->name); @@ -22,7 +23,7 @@ it('shows real provisioning runs to admins', function () { it('gates the provisioning console to admins', function () { $this->get(route('admin.provisioning'))->assertRedirect('/login'); - $this->actingAs(User::factory()->create(['is_admin' => false])) + $this->actingAs(User::factory()->create()) ->get(route('admin.provisioning'))->assertForbidden(); }); diff --git a/tests/Feature/PublicSiteGateTest.php b/tests/Feature/PublicSiteGateTest.php index 1f0b60e..72121b3 100644 --- a/tests/Feature/PublicSiteGateTest.php +++ b/tests/Feature/PublicSiteGateTest.php @@ -2,6 +2,7 @@ use App\Models\User; use App\Support\Settings; +use Illuminate\Support\Facades\Auth; it('serves the site normally while it is public', function () { Settings::set('site.public', true); @@ -37,13 +38,21 @@ it('lets the management VPN through', function () { it('lets a signed-in operator see the real site from anywhere', function () { Settings::set('site.public', false); - $this->actingAs(operator('Support')) + $this->actingAs(operator('Support'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) ->get('/') ->assertOk(); + // Both matter: the operator guard instance keeps its user across requests + // within one test regardless of which guard is "default", so it has to be + // logged out explicitly — and the customer has to be put on 'web' + // explicitly, because a bare actingAs() would target whatever guard is + // still default (still 'operator', from the call above) rather than reset + // to it. + Auth::guard('operator')->logout(); + // A customer account is not staff — the portal stays hidden for them. - $this->actingAs(User::factory()->create()) + $this->actingAs(User::factory()->create(), 'web') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) ->get('/') ->assertStatus(503); @@ -53,7 +62,7 @@ it('keeps the console and the webhook reachable while hidden', function () { Settings::set('site.public', false); // Otherwise the switch could only ever be flipped once. - $this->actingAs(operator('Owner')) + $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) ->get(route('admin.settings')) ->assertOk(); @@ -80,12 +89,12 @@ it('tells crawlers to stay away while hidden', function () { it('flips visibility from the console, and only with the capability', function () { Settings::set('site.public', true); - Livewire\Livewire::actingAs(operator('Support'))->test(App\Livewire\Admin\Settings::class) + Livewire\Livewire::actingAs(operator('Support'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('toggleSiteVisibility') ->assertForbidden(); expect(Settings::bool('site.public', true))->toBeTrue(); - Livewire\Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class) + Livewire\Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('toggleSiteVisibility'); expect(Settings::bool('site.public', true))->toBeFalse(); }); @@ -110,7 +119,7 @@ it('does not leave the portal drivable through Livewire while hidden', function // Operators keep working — that is what the console needs. Whatever // Livewire makes of an empty payload, the gate must not be what answers. - $status = $this->actingAs(operator('Owner')) + $status = $this->actingAs(operator('Owner'), 'operator') ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) ->post('/livewire/update', []) ->getStatusCode(); diff --git a/tests/Feature/ReleaseVersionTest.php b/tests/Feature/ReleaseVersionTest.php index cb21842..21109bc 100644 --- a/tests/Feature/ReleaseVersionTest.php +++ b/tests/Feature/ReleaseVersionTest.php @@ -1,6 +1,6 @@ deployedAt)->toBeNull() ->and(Release::current()->label())->toBe('1.0.0-dev (abc1234) · main'); - $this->actingAs(User::factory()->operator()->create()) + $this->actingAs(Operator::factory()->role()->create(), 'operator') ->get(route('admin.overview')) ->assertOk(); }); @@ -118,7 +118,7 @@ it('shows the running version in the console', function () { 'deployed_at' => now()->toIso8601String(), ]); - $this->actingAs(User::factory()->operator()->create()) + $this->actingAs(Operator::factory()->role()->create(), 'operator') ->get(route('admin.overview')) ->assertOk() ->assertSee(config('app.version').' (abc1234)'); diff --git a/tests/Pest.php b/tests/Pest.php index a7b46a2..6cc5dd3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,7 @@ operator($role)->create(); + return Operator::factory()->role($role)->create(['password' => 'password']); } -function admin(): User +function admin(): Operator { - return User::factory()->create(['is_admin' => true]); + return Operator::factory()->role('Owner')->create(['password' => 'password']); } /**