151 lines
7.6 KiB
PHP
151 lines
7.6 KiB
PHP
<?php
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Operator;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
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');
|
|
}
|
|
});
|
|
|
|
it('remaps vpn_peers and maintenance_windows ownership to the new operator, not just the role tables', function () {
|
|
// Simulates the live server this was written for: an operator with a VPN
|
|
// peer per device and at least one scheduled maintenance window. Both
|
|
// columns are foreign keys into `users` that have only ever held an
|
|
// operator's id — proving they end up on the new operators.id, and not
|
|
// null, is the only test that actually exercises the live case; a fresh
|
|
// database has no rows in either table for the migration to get wrong.
|
|
//
|
|
// A throwaway operator goes first, purely to consume operators.id=1: the
|
|
// in-memory test database restarts both the users and operators id
|
|
// sequences near 1 every test, so without this, netzwerk@clupilot.test's
|
|
// users.id and its own new operators.id would coincide — and a remap
|
|
// that was skipped entirely would still leave user_id/created_by
|
|
// "correct" by coincidence rather than by the migration doing its job.
|
|
// (Caught this exact way, by mutation: the first version of this test had
|
|
// no such guard and stayed green with the remap commented out.)
|
|
Operator::factory()->create(); // consumes operators.id — see the comment above.
|
|
|
|
$hash = bcrypt('netzwerk-passwort');
|
|
DB::table('users')->insert([
|
|
'name' => 'Netzwerk-Owner', 'email' => 'netzwerk@clupilot.test', 'password' => $hash,
|
|
'is_admin' => true, 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
$userId = DB::table('users')->where('email', 'netzwerk@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,
|
|
]);
|
|
|
|
// RefreshDatabase already migrated once, so vpn_peers.user_id/created_by
|
|
// and maintenance_windows.created_by are already constrained against
|
|
// `operators` — the very thing this migration is supposed to arrange.
|
|
// Hand-building the LEGACY shape (a users.id sitting in those columns)
|
|
// means briefly turning that constraint off, exactly as it would have
|
|
// been before this migration ever ran on a real server. Not
|
|
// Schema::disableForeignKeyConstraints() — SQLite's `PRAGMA foreign_keys`
|
|
// is a documented no-op while a transaction is open, and RefreshDatabase
|
|
// always has one open. `defer_foreign_keys` is the pragma meant for
|
|
// exactly this: it can be toggled mid-transaction and only checks at
|
|
// commit — which this test's wrapping transaction never reaches.
|
|
DB::statement('PRAGMA defer_foreign_keys = ON');
|
|
DB::table('vpn_peers')->insert([
|
|
'uuid' => (string) Str::uuid(), 'name' => 'Notebook', 'kind' => 'staff',
|
|
'user_id' => $userId, 'created_by' => $userId,
|
|
'public_key' => 'PUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
|
'allowed_ip' => '10.66.0.199', 'enabled' => true, 'present' => false,
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
DB::table('maintenance_windows')->insert([
|
|
'uuid' => (string) Str::uuid(), 'title' => 'Netz-Wartung',
|
|
'starts_at' => now()->addDay(), 'ends_at' => now()->addDay()->addHours(2),
|
|
'state' => 'draft', 'created_by' => $userId,
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
(require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up();
|
|
|
|
$operatorId = Operator::where('email', 'netzwerk@clupilot.test')->value('id');
|
|
expect($operatorId)->not->toBeNull()
|
|
// The decoy operator above is what makes this a real assertion: it
|
|
// took operators.id 1, so netzwerk's own operator cannot be 1 too —
|
|
// the id genuinely has to have changed, not merely be numerically
|
|
// unchanged by coincidence.
|
|
->and($operatorId)->not->toBe($userId);
|
|
|
|
$peer = DB::table('vpn_peers')->where('name', 'Notebook')->first();
|
|
expect($peer->user_id)->not->toBeNull()->and($peer->user_id)->toBe($operatorId)
|
|
->and($peer->created_by)->not->toBeNull()->and($peer->created_by)->toBe($operatorId);
|
|
|
|
$window = DB::table('maintenance_windows')->where('title', 'Netz-Wartung')->first();
|
|
expect($window->created_by)->not->toBeNull()->and($window->created_by)->toBe($operatorId);
|
|
});
|