Initial commit device-management
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 09:42:00 +07:00
commit 1e80be180e
1159 changed files with 149545 additions and 0 deletions
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Administration;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class TenantManagementTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
public function test_platform_admin_can_create_tenant_with_owner_and_default_roles(): void
{
$admin = User::factory()->create(['is_platform_admin' => true]);
$response = $this->actingAs($admin)->post('/administration/tenants', [
'name' => 'ISP Jakarta',
'slug' => 'isp-jakarta',
'is_active' => true,
'owner_name' => 'Tenant Owner',
'owner_email' => 'owner@example.test',
'owner_password' => 'Strong!Password123',
'owner_password_confirmation' => 'Strong!Password123',
]);
$tenant = Tenant::where('slug', 'isp-jakarta')->firstOrFail();
$owner = User::withoutGlobalScope('tenant')->where('email', 'owner@example.test')->firstOrFail();
$response->assertRedirect(route('administration.tenants.edit', $tenant));
$this->assertSame($tenant->id, $owner->tenant_id);
$this->assertTrue(Hash::check('Strong!Password123', $owner->password));
$this->assertNotNull($owner->email_verified_at);
$this->assertDatabaseCount('roles', 3);
$this->assertDatabaseHas('roles', ['tenant_id' => $tenant->id, 'name' => 'TENANT ADMIN']);
$this->assertDatabaseHas('roles', ['tenant_id' => $tenant->id, 'name' => 'TENANT USER']);
$this->assertDatabaseHas('model_has_roles', ['tenant_id' => $tenant->id, 'model_id' => $owner->id]);
}
public function test_normal_user_cannot_open_platform_tenant_administration(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$this->actingAs($user)->get('/administration/tenants')->assertForbidden();
}
public function test_tenant_with_users_cannot_be_deleted(): void
{
$admin = User::factory()->create(['is_platform_admin' => true]);
$tenant = Tenant::factory()->create();
User::factory()->for($tenant)->create();
$this->actingAs($admin)->delete(route('administration.tenants.destroy', $tenant))->assertStatus(422);
$this->assertDatabaseHas('tenants', ['id' => $tenant->id]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Administration;
use App\Models\Tenant;
use App\Models\User;
use App\Services\TenantProvisioningService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class UserManagementTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
public function test_tenant_owner_can_create_update_reset_and_disable_user(): void
{
[$tenant, $owner] = $this->tenantWithOwner('tenant-a');
$this->actingAs($owner)->post(route('administration.users.store', $tenant), [
'name' => 'NOC Operator', 'email' => 'noc@example.test', 'password' => 'Strong!Password123',
'password_confirmation' => 'Strong!Password123',
])->assertRedirect(route('administration.users.index', $tenant));
$user = User::withoutGlobalScope('tenant')->where('email', 'noc@example.test')->firstOrFail();
$this->assertSame($tenant->id, $user->tenant_id);
setPermissionsTeamId($tenant->id);
$this->assertTrue($user->hasRole('TENANT USER'));
$this->actingAs($owner)->put(route('administration.users.password', [$tenant, $user]), [
'password' => 'Changed!Password123', 'password_confirmation' => 'Changed!Password123',
])->assertRedirect();
$this->assertTrue(Hash::check('Changed!Password123', $user->fresh()->password));
$this->actingAs($owner)->patch(route('administration.users.toggle-active', [$tenant, $user]))->assertRedirect();
$this->assertFalse($user->fresh()->is_active);
}
public function test_tenant_owner_cannot_access_other_tenant_administration(): void
{
[, $ownerA] = $this->tenantWithOwner('tenant-a');
[$tenantB] = $this->tenantWithOwner('tenant-b');
$this->actingAs($ownerA)->get(route('administration.users.index', $tenantB))->assertForbidden();
}
public function test_tenant_admin_is_protected_from_deletion(): void
{
[$tenant, $owner] = $this->tenantWithOwner('tenant-a');
$this->actingAs(User::factory()->create(['is_platform_admin' => true]))
->delete(route('administration.users.destroy', [$tenant, $owner]))
->assertStatus(422);
}
public function test_tenant_admin_cannot_manage_another_tenant_admin(): void
{
[$tenant, $admin] = $this->tenantWithOwner('tenant-a');
$this->actingAs($admin)
->get(route('administration.users.edit', [$tenant, $admin]))
->assertForbidden();
}
/** @return array{Tenant, User} */
private function tenantWithOwner(string $slug): array
{
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => strtoupper($slug), 'slug' => $slug, 'owner_name' => 'Owner '.$slug,
'owner_email' => $slug.'@example.test', 'owner_password' => 'Strong!Password123',
]);
return [$tenant, User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail()];
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Fortify\Features;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered()
{
$response = $this->get(route('login'));
$response->assertOk();
}
public function test_users_can_authenticate_using_the_login_screen()
{
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect(route('two-factor.login'));
$response->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response->assertRedirect(route('home'));
$this->assertGuest();
}
public function test_users_are_rate_limited()
{
$user = User::factory()->create();
RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertTooManyRequests();
}
}
@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::emailVerification());
}
public function test_email_verification_screen_can_be_rendered()
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
}
public function test_email_can_be_verified()
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash()
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_email_is_not_verified_with_invalid_user_id(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_verified_user_is_redirected_to_dashboard_from_verification_prompt(): void
{
$user = User::factory()->create();
Event::fake();
$response = $this->actingAs($user)->get(route('verification.notice'));
Event::assertNotDispatched(Verified::class);
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{
$user = User::factory()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
Event::assertNotDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('auth/confirm-password'),
);
}
public function test_password_confirmation_requires_authentication()
{
$response = $this->get(route('password.confirm'));
$response->assertRedirect(route('login'));
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::resetPasswords());
}
public function test_reset_password_link_screen_can_be_rendered()
{
$response = $this->get(route('password.request'));
$response->assertOk();
}
public function test_reset_password_link_can_be_requested()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
$response->assertOk();
return true;
});
}
public function test_password_can_be_reset_with_valid_token()
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post(route('password.update'), [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
public function test_password_cannot_be_reset_with_invalid_token(): void
{
$user = User::factory()->create();
$response = $this->post(route('password.update'), [
'token' => 'invalid-token',
'email' => $user->email,
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertSessionHasErrors('email');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::registration());
}
public function test_registration_screen_can_be_rendered()
{
$response = $this->get(route('register'));
$response->assertOk();
}
public function test_new_users_can_register()
{
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
use Tests\TestCase;
class TwoFactorChallengeTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
}
public function test_two_factor_challenge_redirects_to_login_when_not_authenticated(): void
{
$response = $this->get(route('two-factor.login'));
$response->assertRedirect(route('login'));
}
public function test_two_factor_challenge_can_be_rendered(): void
{
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$this->get(route('two-factor.login'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('auth/two-factor-challenge'),
);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class VerificationNotificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::emailVerification());
}
public function test_sends_verification_notification(): void
{
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertSentTo($user, VerifyEmail::class);
}
public function test_does_not_send_verification_notification_if_email_is_verified(): void
{
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('dashboard', absolute: false));
Notification::assertNothingSent();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\TenantProvisioningService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
public function test_guests_are_redirected_to_the_login_page()
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_can_visit_the_dashboard()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
}
public function test_master_admin_receives_platform_dashboard(): void
{
$admin = User::factory()->create(['is_platform_admin' => true]);
$this->actingAs($admin)->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->component('dashboard')
->where('role', 'MASTER ADMIN')
->where('tenant', null)
->has('stats.tenants')
->has('stats.activeLicenses'));
}
public function test_tenant_admin_receives_tenant_admin_dashboard(): void
{
[$tenant, $admin] = $this->tenantWithAdmin();
$this->actingAs($admin)->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->where('role', 'TENANT ADMIN')
->where('tenant.id', $tenant->id)
->where('stats.users', 1));
}
public function test_tenant_user_receives_tenant_user_dashboard(): void
{
[$tenant, $admin] = $this->tenantWithAdmin();
setPermissionsTeamId($tenant->id);
$user = User::factory()->for($tenant)->create();
$user->assignRole('TENANT USER');
$this->actingAs($user)->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->where('role', 'TENANT USER')
->where('tenant.id', $tenant->id)
->where('stats.canAddDevice', true));
}
private function tenantWithAdmin(): array
{
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin Tenant',
'owner_email' => 'admin@tenant.test', 'owner_password' => 'Strong!Password123',
]);
return [$tenant, User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail()];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Tests\Feature;
use App\Jobs\SyncDeviceUser;
use App\Models\Device;
use App\Models\DeviceAccessUser;
use App\Models\DeviceType;
use App\Models\DeviceUserAssignment;
use App\Models\DeviceVendor;
use App\Models\User;
use App\Services\TenantProvisioningService;
use App\Support\TenantContext;
use Database\Seeders\DeviceCatalogSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class DeviceAccessUserTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed([RolePermissionSeeder::class, DeviceCatalogSeeder::class]);
}
public function test_one_central_user_can_be_queued_for_multiple_devices_without_password_in_job(): void
{
Queue::fake();
[$tenant, $admin] = $this->tenant('tenant-a');
$devices = collect([$this->device($tenant->id, $admin->id), $this->device($tenant->id, $admin->id, '10.0.0.2')]);
$this->actingAs($admin)->post(route('device-users.store'), [
'display_name' => 'Operator NOC', 'username' => 'noc.operator',
'password' => 'Device!Password123', 'password_confirmation' => 'Device!Password123',
'group_name' => 'RADIQ-NOC', 'device_ids' => $devices->pluck('id')->all(), 'is_enabled' => true,
])->assertRedirect(route('device-users.index'));
$accessUser = DeviceAccessUser::withoutGlobalScope('tenant')->firstOrFail();
$this->assertSame('Device!Password123', $accessUser->password);
$this->assertNotSame('Device!Password123', DB::table('device_access_users')->value('password'));
$this->assertDatabaseCount('device_user_assignments', 2);
Queue::assertPushed(SyncDeviceUser::class, 2);
Queue::assertPushed(SyncDeviceUser::class, fn (SyncDeviceUser $job) => $job->tenantId === $tenant->id && ! property_exists($job, 'password'));
}
public function test_unsupported_vendor_is_recorded_without_losing_pending_work(): void
{
[$tenant, $admin] = $this->tenant('tenant-a');
$device = $this->device($tenant->id, $admin->id);
$user = DeviceAccessUser::create(['tenant_id' => $tenant->id, 'display_name' => 'Operator', 'username' => 'operator', 'password' => 'Device!Password123']);
$assignment = DeviceUserAssignment::create(['tenant_id' => $tenant->id, 'device_access_user_id' => $user->id, 'device_id' => $device->id, 'group_name' => 'RADIQ-NOC']);
(new SyncDeviceUser($tenant->id, $assignment->id))->handle(app(TenantContext::class));
$this->assertSame('unsupported', $assignment->fresh()->sync_status);
$this->assertSame('DRIVER_NOT_AVAILABLE', $assignment->fresh()->error_code);
}
private function tenant(string $slug): array
{
$tenant = app(TenantProvisioningService::class)->createWithOwner(['name' => strtoupper($slug), 'slug' => $slug, 'owner_name' => 'Admin', 'owner_email' => $slug.'@test.local', 'owner_password' => 'Strong!Password123']);
return [$tenant, User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail()];
}
private function device(int $tenantId, int $adminId, string $address = '10.0.0.1'): Device
{
return Device::create(['tenant_id' => $tenantId, 'name' => 'Device '.$address, 'device_vendor_id' => DeviceVendor::where('slug', 'zte')->first()->id, 'device_type_id' => DeviceType::first()->id, 'management_address' => $address, 'management_port' => 22, 'connection_type' => 'ssh', 'created_by' => $adminId]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature;
use App\Enums\SystemRole;
use App\Models\Device;
use App\Models\DeviceCredential;
use App\Models\DeviceType;
use App\Models\DeviceVendor;
use App\Models\User;
use App\Services\TenantProvisioningService;
use Database\Seeders\DeviceCatalogSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class DeviceInventoryTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed([RolePermissionSeeder::class, DeviceCatalogSeeder::class]);
}
public function test_tenant_admin_can_create_device_and_other_tenant_cannot_see_it(): void
{
[$tenantA, $adminA] = $this->tenant('tenant-a');
[, $adminB] = $this->tenant('tenant-b');
$vendor = DeviceVendor::where('slug', 'mikrotik')->firstOrFail();
$type = DeviceType::where('slug', 'router')->firstOrFail();
$this->actingAs($adminA)->post(route('devices.store'), [
'name' => 'Router Jakarta', 'management_address' => 'router.example.test',
'management_port' => 8728, 'connection_type' => 'routeros_api',
'device_vendor_id' => $vendor->id, 'device_type_id' => $type->id,
'initial_username' => 'admin', 'initial_password' => '',
'remove_legacy_users_on_activation' => true,
])->assertRedirect(route('devices.index'));
$device = Device::withoutGlobalScope('tenant')->firstOrFail();
$this->assertSame($tenantA->id, $device->tenant_id);
$credential = DeviceCredential::withoutGlobalScope('tenant')->where('device_id', $device->id)->firstOrFail();
$this->assertSame('admin', $credential->username);
$this->assertSame('', $credential->password);
$this->assertTrue($device->remove_legacy_users_on_activation);
$this->actingAs($adminB)->get(route('devices.show', $device))->assertNotFound();
}
public function test_tenant_user_create_access_follows_tenant_policy(): void
{
[$tenant, $admin] = $this->tenant('tenant-a');
setPermissionsTeamId($tenant->id);
$user = User::factory()->for($tenant)->create();
$user->assignRole(SystemRole::TenantUser->value);
$tenant->devicePolicy()->update(['tenant_user_can_create' => false]);
$this->actingAs($user)->get(route('devices.create'))->assertForbidden();
$this->actingAs($admin)->put(route('device-policy.update'), [
'tenant_user_can_create' => true, 'tenant_user_can_update_own' => true,
'tenant_user_can_delete_own' => false,
])->assertRedirect();
$this->actingAs($user)->get(route('devices.create'))->assertOk();
}
public function test_device_password_is_encrypted_and_hidden(): void
{
[$tenant, $admin] = $this->tenant('tenant-a');
$device = Device::create([
'tenant_id' => $tenant->id, 'name' => 'Router', 'device_vendor_id' => DeviceVendor::first()->id,
'device_type_id' => DeviceType::first()->id, 'management_address' => '10.0.0.1',
'management_port' => 22, 'connection_type' => 'ssh', 'created_by' => $admin->id,
]);
$credential = DeviceCredential::create([
'tenant_id' => $tenant->id, 'device_id' => $device->id, 'name' => 'Management',
'username' => 'admin', 'password' => 'DeviceSecret!123', 'connection_type' => 'ssh',
]);
$this->assertNotSame('DeviceSecret!123', DB::table('device_credentials')->value('password'));
$this->assertSame('DeviceSecret!123', $credential->password);
$this->assertArrayNotHasKey('password', $credential->toArray());
}
private function tenant(string $slug): array
{
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => strtoupper($slug), 'slug' => $slug, 'owner_name' => 'Admin '.$slug,
'owner_email' => $slug.'@test.local', 'owner_password' => 'Strong!Password123',
]);
return [$tenant, User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail()];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_returns_a_successful_response()
{
$response = $this->get(route('home'));
$response->assertOk();
}
}
@@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Enums\DeploymentMode;
use App\Enums\LicenseStatus;
use App\Models\DeploymentInstallation;
use App\Models\License;
use App\Models\Tenant;
use App\Support\TenantContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LicensingArchitectureTest extends TestCase
{
use RefreshDatabase;
public function test_license_and_installation_are_tenant_scoped(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$installation = DeploymentInstallation::create([
'tenant_id' => $tenantA->id,
'name' => 'ISP A Server',
'deployment_type' => DeploymentMode::SelfHosted,
'instance_key_hash' => hash('sha256', 'instance-secret'),
]);
License::create([
'tenant_id' => $tenantA->id,
'deployment_installation_id' => $installation->id,
'license_key_hash' => hash('sha256', 'RNDM-RAW-KEY'),
'plan' => 'professional',
'status' => LicenseStatus::Active,
'starts_at' => now(),
'expires_at' => now()->addYear(),
]);
app(TenantContext::class)->set($tenantB->id);
$this->assertCount(0, License::all());
$this->assertCount(0, DeploymentInstallation::all());
}
public function test_self_hosted_installation_cannot_create_platform_admin(): void
{
config()->set('deployment.mode', DeploymentMode::SelfHosted->value);
$this->artisan('app:create-platform-admin')
->expectsOutput('Platform Super Admin hanya dibuat pada RADIQ control plane atau managed cloud.')
->assertFailed();
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Models\TenantDeviceSetting;
use App\Models\User;
use App\Network\Clients\RouterOs\RouterOsApiClient;
use App\Network\Drivers\Mikrotik\MikrotikDriver;
use App\Services\TenantProvisioningService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class MikrotikProvisioningTest extends TestCase
{
use RefreshDatabase;
public function test_tenant_admin_can_store_encrypted_base_device_password(): void
{
$this->seed(RolePermissionSeeder::class);
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin',
'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123',
]);
$admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail();
$this->actingAs($admin)->put(route('device-settings.update'), [
'base_username' => 'radiq-manager', 'base_password' => 'Device!Password123',
'base_password_confirmation' => 'Device!Password123', 'use_tls' => false,
'verify_tls' => true, 'connection_timeout' => 10,
])->assertRedirect();
$setting = TenantDeviceSetting::withoutGlobalScope('tenant')->firstOrFail();
$this->assertSame('Device!Password123', $setting->base_password);
$this->assertNotSame('Device!Password123', DB::table('tenant_device_settings')->value('base_password'));
$this->assertArrayNotHasKey('base_password', $setting->toArray());
}
public function test_tenant_admin_must_confirm_login_password_to_reveal_base_password(): void
{
$this->seed(RolePermissionSeeder::class);
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin',
'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123',
]);
$admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail();
TenantDeviceSetting::create([
'tenant_id' => $tenant->id, 'base_username' => 'radiq-manager',
'base_password' => 'Device!Password123', 'connection_timeout' => 10,
]);
$this->actingAs($admin)->postJson(route('device-settings.reveal-password'), [
'current_password' => 'wrong-password',
])->assertUnprocessable()->assertJsonValidationErrors('current_password');
$this->actingAs($admin)->postJson(route('device-settings.reveal-password'), [
'current_password' => 'Strong!Password123',
])->assertOk()->assertExactJson(['password' => 'Device!Password123'])->assertHeader('Cache-Control', 'no-store, private');
}
public function test_key_rotation_reencrypts_existing_secrets_without_changing_plaintext(): void
{
$this->seed(RolePermissionSeeder::class);
$tenant = app(TenantProvisioningService::class)->createWithOwner([
'name' => 'Tenant A', 'slug' => 'tenant-a', 'owner_name' => 'Admin',
'owner_email' => 'admin@test.local', 'owner_password' => 'Strong!Password123',
]);
$admin = User::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->firstOrFail();
TenantDeviceSetting::create([
'tenant_id' => $tenant->id, 'base_username' => 'radiq-manager',
'base_password' => 'Device!Password123', 'connection_timeout' => 10,
]);
$before = DB::table('tenant_device_settings')->value('base_password');
$this->actingAs($admin)->post(route('device-settings.rotate-key'), [
'mode' => 'generated', 'current_password' => 'Strong!Password123',
])->assertRedirect();
$after = DB::table('tenant_device_settings')->value('base_password');
$this->assertNotSame($before, $after);
$this->assertSame('Device!Password123', TenantDeviceSetting::withoutGlobalScope('tenant')->firstOrFail()->base_password);
$this->assertDatabaseCount('tenant_encryption_keys', 2);
$this->assertDatabaseHas('tenant_encryption_keys', ['tenant_id' => $tenant->id, 'version' => 2, 'is_active' => true]);
}
public function test_mikrotik_groups_exclude_policy_and_base_user_uses_full(): void
{
$client = new FakeRouterOsClient;
(new MikrotikDriver($client))->provisionBaseAccess('radiq-manager', 'Device!Password123');
$groups = collect($client->commands)->filter(fn (array $command) => $command[0] === '/user/group/add');
foreach (['RADIQ-READ', 'RADIQ-WRITE', 'RADIQ-NOC'] as $name) {
$command = $groups->first(fn (array $item) => in_array('=name='.$name, $item, true));
$policy = collect($command)->first(fn (string $word) => str_starts_with($word, '=policy='));
$this->assertStringNotContainsString(',policy,', ','.str($policy)->after('=policy=').',');
}
$this->assertFalse($groups->contains(fn (array $item) => in_array('=name=RADIQ-MANAGER', $item, true)));
$baseUser = collect($client->commands)->first(fn (array $item) => $item[0] === '/user/add');
$this->assertContains('=group=full', $baseUser);
}
public function test_mikrotik_inventory_and_legacy_cleanup_preserve_full_users(): void
{
$client = new FakeRouterOsClient;
$client->responses['/system/identity/print'] = [['name' => 'CCR-Jakarta']];
$client->responses['/system/routerboard/print'] = [['model' => 'CCR2004', 'serial-number' => 'ABC123', 'current-firmware' => '7.20']];
$client->responses['/system/resource/print'] = [['version' => '7.20', 'architecture-name' => 'arm64']];
$client->responses['/user/print'] = [
['.id' => '*1', 'name' => 'radiq-manager', 'group' => 'full'],
['.id' => '*2', 'name' => 'emergency-admin', 'group' => 'full'],
['.id' => '*3', 'name' => 'old-noc', 'group' => 'read'],
];
$driver = new MikrotikDriver($client);
$this->assertSame('ABC123', $driver->getDeviceInfo()['serial-number']);
$result = $driver->cleanupLegacyUsers('radiq-manager');
$this->assertSame(['old-noc'], $result['deleted']);
$this->assertContains(['/user/remove', '=.id=*3'], $client->commands);
$this->assertNotContains(['/user/remove', '=.id=*2'], $client->commands);
}
public function test_unsupported_device_fact_does_not_cancel_other_inventory(): void
{
$client = new FakeRouterOsClient;
$client->responses['/system/identity/print'] = [['name' => 'Router ROS']];
$client->responses['/system/resource/print'] = [['version' => '6.49.17', 'uptime' => '1d']];
$client->failWhenProplistContains = 'cpu';
$facts = (new MikrotikDriver($client))->getDeviceInfo();
$this->assertSame('Router ROS', $facts['identity']);
$this->assertSame('6.49.17', $facts['version']);
$this->assertArrayNotHasKey('cpu', $facts);
}
}
class FakeRouterOsClient extends RouterOsApiClient
{
public array $commands = [];
public array $responses = [];
public ?string $failWhenProplistContains = null;
public function __construct() {}
public function connect(): void {}
public function disconnect(): void {}
public function command(array $words): array
{
$this->commands[] = $words;
if ($this->failWhenProplistContains && in_array('=.proplist='.$this->failWhenProplistContains, $words, true)) {
throw new \RuntimeException('CONNECTION_TIMEOUT: unsupported fact');
}
if (array_key_exists($words[0], $this->responses)) {
return $this->responses[$words[0]];
}
if (str_ends_with($words[0], '/print')) {
return [];
}
return [];
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class PermissionTest extends TestCase
{
use RefreshDatabase;
public function test_role_assignments_are_tenant_scoped(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->for($tenantA)->create();
$userB = User::factory()->for($tenantB)->create();
$permission = Permission::create(['name' => 'device.view', 'guard_name' => 'web']);
setPermissionsTeamId($tenantA->id);
$roleA = Role::create(['name' => 'NOC', 'guard_name' => 'web']);
$roleA->givePermissionTo($permission);
$userA->assignRole($roleA);
setPermissionsTeamId($tenantB->id);
$roleB = Role::create(['name' => 'NOC', 'guard_name' => 'web']);
$userB->assignRole($roleB);
setPermissionsTeamId($tenantA->id);
$this->assertTrue($userA->fresh()->can('device.view'));
setPermissionsTeamId($tenantB->id);
$this->assertFalse($userB->fresh()->can('device.view'));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
class PlatformAdminTest extends TestCase
{
use RefreshDatabase;
public function test_command_creates_platform_admin_without_a_tenant(): void
{
$this->artisan('app:create-platform-admin', [
'--name' => 'Platform Administrator',
'--email' => 'admin@example.test',
])
->expectsQuestion('Password (minimum 12 characters)', 'Strong!Password123')
->expectsQuestion('Confirm password', 'Strong!Password123')
->expectsOutput('Platform Master Admin created successfully.')
->assertSuccessful();
$admin = User::withoutGlobalScope('tenant')->firstOrFail();
$this->assertNull($admin->tenant_id);
$this->assertTrue($admin->is_platform_admin);
$this->assertTrue(Gate::forUser($admin)->allows('tenant.access-any'));
}
public function test_normal_tenant_admin_does_not_receive_platform_access(): void
{
$user = User::factory()->create(['is_platform_admin' => false]);
$this->assertFalse(Gate::forUser($user)->allows('tenant.access-any'));
}
}
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('profile.edit'));
$response->assertOk();
}
public function test_profile_information_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete(route('profile.destroy'), [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('home'));
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('profile.edit'))
->delete(route('profile.destroy'), [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect(route('profile.edit'));
$this->assertNotNull($user->fresh());
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
use Tests\TestCase;
class SecurityTest extends TestCase
{
use RefreshDatabase;
public function test_security_page_is_displayed()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
Features::passkeys([
'confirmPassword' => true,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', true)
->where('passkeys', [])
->where('canManageTwoFactor', true)
->where('twoFactorEnabled', false),
);
}
public function test_security_page_requires_password_confirmation_when_enabled()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$response = $this->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
}
public function test_security_page_renders_without_two_factor_when_feature_is_disabled()
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', false)
->where('passkeys', [])
->where('canManageTwoFactor', false)
->missing('twoFactorEnabled')
->missing('requiresConfirmation'),
);
}
public function test_password_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('security.edit'));
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('security.edit'));
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
use RefreshDatabase;
public function test_user_queries_are_scoped_to_current_tenant(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
User::factory()->for($tenantA)->create();
User::factory()->for($tenantB)->create();
app(TenantContext::class)->set($tenantA->id);
$this->assertCount(1, User::all());
$this->assertTrue(User::first()->tenant->is($tenantA));
}
public function test_tenant_id_is_assigned_from_context_on_create(): void
{
$tenant = Tenant::factory()->create();
app(TenantContext::class)->set($tenant->id);
$user = User::factory()->create(['tenant_id' => null]);
$this->assertSame($tenant->id, $user->tenant_id);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase
{
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
{
if (! Features::enabled($feature)) {
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}