Move the console's identity out of the customer table
parent
8d1fa52a5a
commit
87c49de6e5
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'] ?? []));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<UserFactory> */
|
||||
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.
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Operator;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
// RuntimeException is a global-namespace class — a `use` for it is a no-op,
|
||||
// and this repo turns that notice into a fatal ErrorException during tests.
|
||||
|
||||
/**
|
||||
* RBAC moves to the operator guard. It is a move, not a copy.
|
||||
*
|
||||
* Every one of the seventeen permissions is a console permission — there is no
|
||||
* customer-facing one among them — so duplicating the set under a second guard
|
||||
* would create two role sets that drift apart. Moving them leaves `users` with
|
||||
* no roles at all, which is exactly right.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
app(PermissionRegistrar::class)->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* A gap the guard-switch brief did not name: three columns are foreign keys
|
||||
* into `users` and hold nothing but operator ids.
|
||||
*
|
||||
* - vpn_peers.user_id / .created_by (2026_07_25_200000, _210000) — a staff
|
||||
* VPN access. VpnPeerFactory's own default owner is
|
||||
* `Operator::factory()->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<int, array{table: string, column: string, onDelete: string}> */
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -22,9 +22,7 @@
|
|||
<x-ui.icon name="alert-triangle" class="size-4 text-warning" />
|
||||
<span class="font-semibold">{{ __('dashboard.no_customer_title') }}</span>
|
||||
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
|
||||
@if (auth()->user()->can('console.view'))
|
||||
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
|
||||
@endif
|
||||
{{-- The console link is gone: a portal account is never an operator (R21). --}}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
|
|
@ -7,7 +8,7 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
|||
});
|
||||
|
||||
// Operator console live provisioning feed — operators only.
|
||||
Broadcast::channel('admin.runs', fn ($user) => (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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Operator;
|
||||
use App\Models\User;
|
||||
|
||||
$adminRoutes = ['admin.overview', 'admin.customers', 'admin.instances', 'admin.hosts', 'admin.provisioning', 'admin.revenue'];
|
||||
|
|
@ -9,20 +10,20 @@ it('redirects guests from the admin console to login', function (string $route)
|
|||
})->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'))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
it('moves every permission and role to the operator guard, leaving none behind', function () {
|
||||
expect(Permission::where('guard_name', 'web')->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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\HttpStripeClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -23,7 +23,7 @@ it('prefers a stored key over the environment, and says which is in force', func
|
|||
expect($vault->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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\User;
|
||||
|
||||
// admin() helper is defined in HostManagementTest.
|
||||
|
|
@ -9,7 +10,7 @@ it('lets an admin impersonate a customer and returns to the admin session', func
|
|||
$admin = admin();
|
||||
$customer = Customer::factory()->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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[^>]*>(?:(?!<\/td>).)*<input/s')
|
||||
->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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Livewire\ConfirmCancelPackage;
|
||||
use App\Livewire\ConfirmCloseAccount;
|
||||
use App\Livewire\Settings;
|
||||
use App\Livewire\Users;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* An operator account has no Customer. Portal actions must SAY so rather than
|
||||
* silently doing nothing (which reads as a dead button).
|
||||
*/
|
||||
function operatorUser(): User
|
||||
{
|
||||
return User::factory()->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'));
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Deployment\Release;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ it('does not take the console down over an unparseable timestamp', function () {
|
|||
expect(Release::current()->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)');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\User;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Dns\FakeHetznerDnsClient;
|
||||
use App\Services\Dns\HetznerDnsClient;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -93,15 +93,24 @@ function fakeServices(): array
|
|||
/*
|
||||
| Shared account helpers. Defined here — NOT in an individual test file — so a
|
||||
| targeted run (pest tests/Feature/Admin/OneFile.php) still resolves them.
|
||||
|
|
||||
| Both return an Operator, on the operator guard — never a User. Callers pass
|
||||
| the result to actingAs()/Livewire::actingAs() with an explicit 'operator'
|
||||
| guard; these helpers cannot set that for you because they only build the
|
||||
| account, they do not sign anyone in.
|
||||
|
|
||||
| The password is fixed at 'password' (OperatorFactory's own default is
|
||||
| different) because several tests exercise the real sign-in/password-change
|
||||
| flow against literally that string.
|
||||
*/
|
||||
function operator(string $role): User
|
||||
function operator(string $role): Operator
|
||||
{
|
||||
return User::factory()->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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue