- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
298 lines
14 KiB
PHP
298 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\Admin\IpPool;
|
|
|
|
use App\Mail\IpAllocationMail;
|
|
use App\Models\Customer;
|
|
use App\Models\IpPool;
|
|
use App\Models\IpPoolLog;
|
|
use App\Models\Mail as MailLog;
|
|
use App\Models\ModuleIpAddress;
|
|
use App\Models\ModuleNetwork;
|
|
use App\Models\Template;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class IpAllocationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const BASE = '/api/v1/administrator/ip-pool';
|
|
|
|
private User $admin;
|
|
private Customer $customer;
|
|
private ModuleNetwork $network;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->seed(\Database\Seeders\TemplateSeeder::class);
|
|
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
|
$this->admin = User::factory()->create(['username' => 'kythuat']);
|
|
$this->admin->assignRole('Full Administrator');
|
|
$this->customer = Customer::factory()->create(['fullname' => 'Công ty A', 'email' => 'a@example.com']);
|
|
$this->network = ModuleNetwork::create(['name' => 'VM Network 56']);
|
|
}
|
|
|
|
private function pool(string $ip, array $attributes = []): IpPool
|
|
{
|
|
return IpPool::create([
|
|
'ip_address' => $ip, 'module_network_id' => $this->network->id, 'cidr' => 22,
|
|
'gateway' => '10.0.0.1', 'status' => 'free', ...$attributes,
|
|
]);
|
|
}
|
|
|
|
private function allocation(array $ids): array
|
|
{
|
|
return [
|
|
'ids' => $ids, 'customer_uuid' => $this->customer->uuid, 'to' => 'a@example.com',
|
|
'cc' => 'kythuat@tgs.com.vn', 'date' => '2026-10-15', 'service_name' => 'Colo <b>1U</b>',
|
|
];
|
|
}
|
|
|
|
public function test_preview_lists_ips_without_changing_them(): void
|
|
{
|
|
$pool = $this->pool('10.0.0.5');
|
|
|
|
$response = $this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocation/preview', $this->allocation([$pool->id]))
|
|
->assertOk()
|
|
->assertJsonPath('data.subject', 'Thông báo cấp phát 1 địa chỉ IP');
|
|
|
|
$this->assertStringContainsString('10.0.0.5', $response->json('data.html'));
|
|
$this->assertStringContainsString('Colo <b>1U</b>', $response->json('data.html'));
|
|
$this->assertSame('free', $pool->fresh()->status);
|
|
}
|
|
|
|
public function test_allocate_assigns_ips_logs_and_emails_customer(): void
|
|
{
|
|
Mail::fake();
|
|
$template = Template::where('scenario', IpAllocationMail::ALLOCATED)->sole();
|
|
$template->update(['metadata' => [...$template->metadata, 'mail' => ['cc' => ['sale@tgs.com.vn'], 'bcc' => ['audit@tgs.com.vn']]]]);
|
|
$first = $this->pool('10.0.0.5');
|
|
$second = $this->pool('10.0.0.6');
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocate', $this->allocation([$first->id, $second->id]))
|
|
->assertOk();
|
|
|
|
$first->refresh();
|
|
$this->assertSame('assigned', $first->status);
|
|
$this->assertSame($this->customer->id, $first->customer_id);
|
|
$this->assertSame('Công ty A', $first->customer_name);
|
|
$this->assertSame('2026-10-15', $first->allocated_at->toDateString());
|
|
$this->assertSame('ALLOCATED_SENT', $first->last_email_type);
|
|
$this->assertSame('kythuat', $first->updated_by);
|
|
|
|
$log = IpPoolLog::where('ip_pool_id', $first->id)->sole();
|
|
$this->assertSame([IpPoolLog::ALLOCATED, 'free', 'assigned', $this->admin->id], [$log->action, $log->status_from, $log->status_to, $log->user_id]);
|
|
$this->assertSame(['2026-10-15', 'Colo <b>1U</b>'], [$log->effective_date->toDateString(), $log->service_name]);
|
|
$this->assertSame(['kythuat@tgs.com.vn, sale@tgs.com.vn', 'audit@tgs.com.vn'], [$log->email_cc, $log->email_bcc]);
|
|
|
|
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->type === IpAllocationMail::ALLOCATED
|
|
&& count($mail->ips) === 2
|
|
&& $mail->hasTo('a@example.com')
|
|
&& $mail->hasCc('kythuat@tgs.com.vn'));
|
|
$this->assertSame(1, MailLog::where('customer_id', $this->customer->id)->count());
|
|
}
|
|
|
|
public function test_each_ip_can_have_its_own_service(): void
|
|
{
|
|
Mail::fake();
|
|
$first = $this->pool('10.0.0.5');
|
|
$second = $this->pool('10.0.0.6');
|
|
$payload = [...$this->allocation([$first->id, $second->id]), 'service_name' => 'Dịch vụ IP', 'services' => [$second->id => 'CLOUD VPS SILVER']];
|
|
|
|
$html = $this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocation/preview', $payload)
|
|
->assertOk()
|
|
->json('data.html');
|
|
$this->assertStringContainsString('CLOUD VPS SILVER', $html);
|
|
$this->assertStringContainsString('Dịch vụ IP', $html);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocate', $payload)
|
|
->assertOk();
|
|
|
|
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Thông báo cấp phát 2 địa chỉ IP');
|
|
$this->assertSame('Dịch vụ IP', $first->fresh()->service_name);
|
|
$this->assertSame('CLOUD VPS SILVER', $second->fresh()->service_name);
|
|
}
|
|
|
|
public function test_admin_can_override_email_subject(): void
|
|
{
|
|
Mail::fake();
|
|
$pool = $this->pool('10.0.0.5');
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocate', [...$this->allocation([$pool->id]), 'subject' => 'Bàn giao IP cho Công ty A'])
|
|
->assertOk();
|
|
|
|
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Bàn giao IP cho Công ty A');
|
|
$this->assertSame('Bàn giao IP cho Công ty A', MailLog::where('customer_id', $this->customer->id)->value('subject'));
|
|
}
|
|
|
|
public function test_allocation_email_carries_uploaded_attachments(): void
|
|
{
|
|
Mail::fake();
|
|
Storage::fake('local');
|
|
$pool = $this->pool('10.0.0.5');
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->post(self::BASE . '/allocate', [
|
|
...$this->allocation([$pool->id]),
|
|
'attachments' => [UploadedFile::fake()->createWithContent('bien-ban.pdf', "%PDF-1.4\n\xff\xfe\x00\x9c binary")],
|
|
], ['Accept' => 'application/json'])
|
|
->assertOk();
|
|
|
|
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => count($mail->storedFiles) === 1
|
|
&& $mail->storedFiles[0]['name'] === 'bien-ban.pdf'
|
|
&& Storage::disk('local')->exists($mail->storedFiles[0]['path'])
|
|
&& count($mail->attachments()) === 1
|
|
&& json_encode(serialize($mail)) !== false);
|
|
}
|
|
|
|
public function test_failed_email_dispatch_rolls_back_the_allocation(): void
|
|
{
|
|
$pool = $this->pool('10.0.0.5');
|
|
Mail::shouldReceive('to')->andThrow(new \RuntimeException('queue down'));
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocate', $this->allocation([$pool->id]))
|
|
->assertStatus(500);
|
|
|
|
$this->assertSame('free', $pool->fresh()->status);
|
|
$this->assertNull($pool->fresh()->customer_id);
|
|
$this->assertSame(0, IpPoolLog::count());
|
|
$this->assertSame(0, MailLog::count());
|
|
}
|
|
|
|
public function test_allocate_rejects_ips_that_are_not_free(): void
|
|
{
|
|
Mail::fake();
|
|
$free = $this->pool('10.0.0.5');
|
|
$taken = $this->pool('10.0.0.6', ['status' => 'assigned']);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/allocate', $this->allocation([$free->id, $taken->id]))
|
|
->assertStatus(422)
|
|
->assertJsonFragment(['ids' => ['Chỉ cấp phát được IP đang trống. IP không hợp lệ: 10.0.0.6']]);
|
|
|
|
$this->assertSame('free', $free->fresh()->status);
|
|
Mail::assertNothingQueued();
|
|
}
|
|
|
|
public function test_revoke_groups_by_customer_and_frees_ips(): void
|
|
{
|
|
Mail::fake();
|
|
$other = Customer::factory()->create(['fullname' => 'Công ty B']);
|
|
$a = $this->pool('10.0.0.5', ['status' => 'assigned', 'customer_id' => $this->customer->id, 'customer_name' => 'Công ty A', 'service_name' => 'CLOUD VPS SILVER', 'contact_email' => 'a@example.com']);
|
|
$b = $this->pool('10.0.0.6', ['status' => 'assigned', 'customer_id' => $other->id, 'customer_name' => 'Công ty B']);
|
|
|
|
$groups = $this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/revocation/preview', ['ids' => [$a->id, $b->id], 'date' => '2026-10-31'])
|
|
->assertOk()
|
|
->assertJsonCount(2, 'data')
|
|
->json('data');
|
|
|
|
$missingEmail = collect($groups)->firstWhere('customer_name', 'Công ty B');
|
|
$this->assertNull($missingEmail['to']);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/revoke', [
|
|
'ids' => [$a->id, $b->id], 'date' => '2026-10-31',
|
|
'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to']])->all(),
|
|
])
|
|
->assertStatus(422);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/revoke', [
|
|
'ids' => [$a->id, $b->id], 'date' => '2026-10-31',
|
|
'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to'] ?? 'b@example.com'])->all(),
|
|
])
|
|
->assertOk();
|
|
|
|
$a->refresh();
|
|
$this->assertSame('free', $a->status);
|
|
$this->assertNull($a->customer_id);
|
|
$this->assertSame('2026-10-31', $a->revoked_at->toDateString());
|
|
$log = IpPoolLog::where('ip_pool_id', $a->id)->sole();
|
|
$this->assertSame([IpPoolLog::REVOKED, 'Công ty A', $this->customer->id], [$log->action, $log->customer_name, $log->customer_id]);
|
|
$this->assertSame(['2026-10-31', 'CLOUD VPS SILVER'], [$log->effective_date->toDateString(), $log->service_name]);
|
|
$this->assertNull($a->service_name);
|
|
Mail::assertQueuedCount(2);
|
|
}
|
|
|
|
public function test_revoke_refuses_ips_used_by_a_vm(): void
|
|
{
|
|
$pool = $this->pool('10.0.0.7', ['status' => 'assigned']);
|
|
ModuleIpAddress::create(['ip' => '10.0.0.7', 'module_network_id' => $this->network->id, 'status' => 1]);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->postJson(self::BASE . '/revocation/preview', ['ids' => [$pool->id], 'date' => '2026-10-31'])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
public function test_logs_endpoint_returns_history(): void
|
|
{
|
|
$pool = $this->pool('10.0.0.8');
|
|
IpPoolLog::record($pool, IpPoolLog::ALLOCATED, 'free', ['customer_name' => 'Công ty A']);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->getJson(self::BASE . "/{$pool->id}/logs")
|
|
->assertOk()
|
|
->assertJsonPath('data.0.action', IpPoolLog::ALLOCATED)
|
|
->assertJsonPath('data.0.customer_name', 'Công ty A');
|
|
}
|
|
|
|
public function test_audit_log_lists_every_ip_with_filters(): void
|
|
{
|
|
IpPoolLog::record($this->pool('10.0.0.8'), IpPoolLog::ALLOCATED, 'free', ['customer_name' => 'Công ty A']);
|
|
IpPoolLog::record($this->pool('10.0.0.9'), IpPoolLog::REVOKED, 'assigned', ['customer_name' => 'Công ty B']);
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->getJson(self::BASE . '/logs')
|
|
->assertOk()
|
|
->assertJsonPath('data.total', 2)
|
|
->assertJsonPath('data.items.0.ip_address', '10.0.0.9');
|
|
|
|
$this->actingAs($this->admin, 'admin')
|
|
->getJson(self::BASE . '/logs?' . http_build_query(['action' => [IpPoolLog::ALLOCATED], 'filter' => 'Công ty']))
|
|
->assertOk()
|
|
->assertJsonPath('data.total', 1)
|
|
->assertJsonPath('data.items.0.ip_address', '10.0.0.8');
|
|
}
|
|
|
|
public function test_dashboard_aggregates_usage_and_money_per_block_and_customer(): void
|
|
{
|
|
$this->pool('10.0.0.5', ['ip_block' => 'A', 'status' => 'assigned', 'customer_id' => $this->customer->id, 'customer_name' => 'Công ty A', 'monthly_cost' => 100, 'selling_price' => 300]);
|
|
$this->pool('10.0.0.6', ['ip_block' => 'A', 'ip_role' => 'ADDON', 'status' => 'assigned', 'customer_name' => 'Khách import', 'selling_price' => 50]);
|
|
$this->pool('10.0.0.7', ['ip_block' => 'A', 'monthly_cost' => 40, 'selling_price' => 999]);
|
|
$this->pool('10.0.0.8', ['ip_block' => 'B', 'status' => 'reserved']);
|
|
|
|
$response = $this->actingAs($this->admin, 'admin')
|
|
->getJson(self::BASE . '/dashboard')
|
|
->assertOk()
|
|
->assertJsonPath('data.summary.total', 4)
|
|
->assertJsonPath('data.summary.primary', 3)
|
|
->assertJsonPath('data.summary.addon', 1)
|
|
->assertJsonPath('data.summary.assigned', 2)
|
|
->assertJsonPath('data.summary.reserved', 1)
|
|
->assertJsonPath('data.blocks.0.ip_block', 'A')
|
|
->assertJsonPath('data.blocks.0.assigned', 2)
|
|
->assertJsonPath('data.top_customers.0.customer_uuid', $this->customer->uuid);
|
|
|
|
$this->assertEquals([140, 350, 210], [$response->json('data.summary.cost'), $response->json('data.summary.revenue'), $response->json('data.summary.profit')]);
|
|
$this->assertEqualsWithDelta(2 / 3, $response->json('data.blocks.0.usage_rate'), 0.0001);
|
|
$this->assertSame(['Công ty A', 'Khách import'], array_column($response->json('data.top_customers'), 'customer_name'));
|
|
}
|
|
}
|