Move the console's identity out of the customer table

feat/operator-identity
nexxo 2026-07-28 10:31:43 +02:00
parent 8d1fa52a5a
commit 87c49de6e5
49 changed files with 868 additions and 517 deletions

View File

@ -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');
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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);
}

View File

@ -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()

View File

@ -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'] ?? []));

View File

@ -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());

View File

@ -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'),
]);
}
}

View File

@ -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(),

View File

@ -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);

View File

@ -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');

View File

@ -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}.",
);

View File

@ -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.
];
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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]));
}
}

View File

@ -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

View File

@ -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();
});
}
};

View File

@ -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();
});
}
}
};

View File

@ -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

View File

@ -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) {

View File

@ -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'))

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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')

View File

@ -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')

View File

@ -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'));

View File

@ -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')

View File

@ -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);
});

View File

@ -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

View File

@ -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();

View File

@ -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'));
});

View File

@ -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);

View File

@ -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');
}
});

View File

@ -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 () {

View File

@ -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);
});

View File

@ -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')

View File

@ -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 operators 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 failures step as this updates 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'])

View File

@ -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
});

View File

@ -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 () {

View File

@ -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.

View File

@ -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();
});

View File

@ -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'));
});

View File

@ -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'));
});

View File

@ -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();
});

View File

@ -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();

View File

@ -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)');

View File

@ -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']);
}
/**