feat: harden admin auth and ticket workflows

- 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
This commit is contained in:
baodev0
2026-10-16 17:00:55 +07:00
parent 0cdf224309
commit 0b08689ea4
52 changed files with 1421 additions and 118 deletions
@@ -11,6 +11,7 @@ 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;
@@ -74,6 +75,8 @@ class IpAllocationTest extends TestCase
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');
@@ -92,6 +95,7 @@ class IpAllocationTest extends TestCase
$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
@@ -248,4 +252,46 @@ class IpAllocationTest extends TestCase
->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'));
}
}
@@ -61,6 +61,7 @@ class PermissionGatingTest extends TestCase
'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'],
'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'],
'tickets index' => ['GET', '/api/v1/administrator/tickets', 'support.view'],
'tickets pending count' => ['GET', '/api/v1/administrator/tickets/pending-count', 'support.view'],
'activity-logs index' => ['GET', '/api/v1/administrator/activity-logs', 'system-log.manager'],
'email-logs index' => ['GET', '/api/v1/administrator/email-logs', 'system-log.manager'],
'post-categories index' => ['GET', '/api/v1/administrator/post-categories', 'post.view'],
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\AdminRefreshToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
class RememberLoginTest extends TestCase
{
use RefreshDatabase;
private const REFRESH_COOKIE = 'admin_refresh_token';
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::factory()->create([
'username' => 'remember-admin',
'password' => Hash::make('Password123!'),
]);
}
public function test_login_only_returns_refresh_token_when_remember_is_enabled(): void
{
$credentials = [
'username' => $this->admin->username,
'password' => 'Password123!',
];
$this->postJson('/api/v1/administrator/login', $credentials)
->assertOk()
->assertJsonMissingPath('data.refresh_token')
->assertCookieExpired(self::REFRESH_COOKIE);
$response = $this->postJson('/api/v1/administrator/login', [...$credentials, 'remember' => true])
->assertOk()
->assertJsonPath('data.refresh_expires_in', 2592000)
->assertJsonMissingPath('data.refresh_token')
->assertCookie(self::REFRESH_COOKIE)
->assertCookieNotExpired(self::REFRESH_COOKIE);
$cookie = $response->getCookie(self::REFRESH_COOKIE, false);
$this->assertTrue($cookie->isHttpOnly());
$this->assertTrue($cookie->isSecure());
$this->assertSame('strict', $cookie->getSameSite());
$this->assertSame('/api/v1/administrator', $cookie->getPath());
$this->assertNotSame($response->json('data.token'), $cookie->getValue());
$this->assertNull(JWTAuth::setToken($response->json('data.token'))->getPayload()->get('remember_until'));
}
public function test_remembered_session_can_refresh_without_a_valid_access_token(): void
{
$login = $this->postJson('/api/v1/administrator/login', [
'username' => $this->admin->username,
'password' => 'Password123!',
'remember' => true,
])->assertOk();
$refreshToken = $login->getCookie(self::REFRESH_COOKIE, false)->getValue();
$this->withCredentials()
->withUnencryptedCookie(self::REFRESH_COOKIE, $refreshToken)
->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders())
->assertOk()
->assertJsonMissingPath('data.refresh_token')
->assertJsonStructure(['data' => ['token', 'expires_in']])
->assertCookie(self::REFRESH_COOKIE);
}
public function test_remembered_session_can_refresh_before_thirty_days(): void
{
$login = $this->postJson('/api/v1/administrator/login', [
'username' => $this->admin->username,
'password' => 'Password123!',
'remember' => true,
])->assertOk();
$this->travelTo(now()->addDays(29));
$this->withCredentials()
->withUnencryptedCookie(
self::REFRESH_COOKIE,
$login->getCookie(self::REFRESH_COOKIE, false)->getValue(),
)->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders())->assertOk();
}
public function test_remembered_session_cannot_refresh_after_thirty_days(): void
{
$login = $this->postJson('/api/v1/administrator/login', [
'username' => $this->admin->username,
'password' => 'Password123!',
'remember' => true,
])->assertOk();
$this->travelTo(now()->addDays(30)->addSecond());
$this->withCredentials()
->withUnencryptedCookie(
self::REFRESH_COOKIE,
$login->getCookie(self::REFRESH_COOKIE, false)->getValue(),
)->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders())
->assertUnauthorized()
->assertCookieExpired(self::REFRESH_COOKIE);
}
public function test_refresh_rejects_body_tokens_and_non_ajax_requests(): void
{
$this->postJson('/api/v1/administrator/refresh', [
'refresh_token' => 'exposed-token',
])->assertForbidden();
$this->postJson('/api/v1/administrator/refresh', [
'refresh_token' => 'exposed-token',
], $this->refreshHeaders())->assertUnprocessable();
}
public function test_logout_revokes_the_http_only_refresh_token(): void
{
$login = $this->postJson('/api/v1/administrator/login', [
'username' => $this->admin->username,
'password' => 'Password123!',
'remember' => true,
])->assertOk();
$refreshToken = $login->getCookie(self::REFRESH_COOKIE, false)->getValue();
$this->assertTrue(AdminRefreshToken::where('token', hash('sha256', $refreshToken))->exists());
$this->withCredentials()
->withToken($login->json('data.token'))
->withUnencryptedCookie(self::REFRESH_COOKIE, $refreshToken)
->postJson('/api/v1/administrator/logout')
->assertOk()
->assertCookieExpired(self::REFRESH_COOKIE);
$this->assertFalse(AdminRefreshToken::where('token', hash('sha256', $refreshToken))->exists());
}
private function refreshHeaders(): array
{
return ['X-Requested-With' => 'XMLHttpRequest'];
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class SystemConfigPasswordVerificationTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$permission = Permission::create(['name' => 'setting.manager', 'guard_name' => 'admin']);
$role = Role::create(['name' => 'settings-manager', 'guard_name' => 'admin']);
$role->givePermissionTo($permission);
$admin = User::factory()->create(['password' => Hash::make('CurrentPassword1')]);
$admin->assignRole($role);
return $admin;
}
public function test_admin_can_verify_their_current_password(): void
{
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/settings/verify-password', [
'password' => 'CurrentPassword1',
])
->assertOk()
->assertJsonPath('error', false);
}
public function test_incorrect_password_is_rejected(): void
{
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/settings/verify-password', [
'password' => 'WrongPassword',
])
->assertUnprocessable()
->assertJsonPath('message', 'Mật khẩu hiện tại không chính xác.');
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Ticket;
use App\Models\Customer;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class AdminTicketTest extends TestCase
{
use RefreshDatabase;
private Customer $customer;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->customer = Customer::factory()->create(['fullname' => 'Nguyễn Văn A']);
$role = Role::create(['name' => 'support', 'guard_name' => 'admin']);
foreach (['support.view', 'support.edit'] as $permission) {
$role->givePermissionTo(Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin']));
}
$this->admin = User::factory()->create(['username' => 'hotro']);
$this->admin->assignRole($role);
}
private function makeTicket(string $status, array $attributes = []): Ticket
{
return Ticket::create(array_merge([
'uuid' => (string) Str::uuid(),
'ticket_num' => 'TKT-' . Str::upper(Str::random(6)),
'subject' => 'Subject',
'description' => 'Description',
'status' => $status,
'room' => 'technical',
'customer_id' => $this->customer->id,
], $attributes));
}
public function test_counts_only_open_and_pending_tickets(): void
{
foreach (['open', 'open', 'pending', 'resolved', 'closed'] as $status) {
$this->makeTicket($status);
}
$this->actingAs($this->admin, 'admin')
->getJson('/api/v1/administrator/tickets/pending-count')
->assertOk()
->assertJsonPath('data.count', 3);
}
public function test_detail_returns_status_history_with_causer(): void
{
$ticket = $this->makeTicket('open');
$this->actingAs($this->admin, 'admin')
->patchJson("/api/v1/administrator/tickets/{$ticket->ticket_num}/status", ['status' => 'pending'])->assertOk();
$this->actingAs($this->admin, 'admin')
->patchJson("/api/v1/administrator/tickets/{$ticket->ticket_num}/status", ['status' => 'resolved'])->assertOk();
$history = $this->actingAs($this->admin, 'admin')
->getJson("/api/v1/administrator/tickets/{$ticket->ticket_num}")
->assertOk()
->json('data.status_history');
$this->assertSame(
[['pending', 'resolved'], ['open', 'pending'], [null, 'open']],
array_map(fn ($log) => [$log['from'], $log['to']], $history),
);
$this->assertSame('hotro', $history[0]['causer_name']);
$this->assertSame('admin', $history[0]['causer_type']);
}
public function test_exports_filtered_tickets_to_branded_excel(): void
{
$this->makeTicket('open', ['subject' => 'VPS không vào được', 'ticket_num' => 'TKT-EXPORT', 'data' => json_encode(['priority' => 'high'])]);
$this->makeTicket('closed', ['subject' => 'Ticket khác']);
$response = $this->actingAs($this->admin, 'admin')
->get('/api/v1/administrator/tickets/export?' . http_build_query(['filter' => 'vps', 'status' => ['open']]))
->assertOk();
$sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet();
$this->assertSame('DANH SÁCH TICKET HỖ TRỢ', $sheet->getCell('A6')->getValue());
$this->assertSame('Mã ticket', $sheet->getCell('A7')->getValue());
$this->assertSame(
['TKT-EXPORT', 'VPS không vào được', 'Nguyễn Văn A', $this->customer->email, 'Kỹ thuật', 'Cao', 'Mở', 0],
array_slice($sheet->rangeToArray('A8:J8', null, true, false)[0], 0, 8),
);
$this->assertNull($sheet->getCell('A9')->getValue());
$this->actingAs(User::factory()->create(), 'admin')
->getJson('/api/v1/administrator/tickets/export')
->assertForbidden();
}
}
@@ -25,6 +25,22 @@ class ClientAuthSecurityTest extends TestCase
return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
public function test_client_routes_require_a_valid_encrypted_application_token(): void
{
$this->getJson('/v1/posts/featured')
->assertForbidden()
->assertJsonPath('message', 'Invalid access token');
$this->withHeaders(['x-access-token' => 'forged-token'])
->getJson('/v1/posts/featured')
->assertForbidden()
->assertJsonPath('message', 'Invalid access token');
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->getJson('/v1/posts/featured')
->assertOk();
}
public function test_access_token_older_than_skew_window_is_rejected(): void
{
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(time() - 3600)])
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Models\Customer;
use App\Models\Ticket;
use App\Mail\Admin\TicketAdminNotification;
use App\Mail\TicketCreated;
use App\Models\TicketReply;
use App\Models\User;
use App\Support\TicketGallery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Tests\TestCase;
class ClientTicketTest extends TestCase
{
use RefreshDatabase;
private Customer $customer;
protected function setUp(): void
{
parent::setUp();
$this->customer = Customer::factory()->create(['status' => 1]);
}
private function makeTicket(Customer $owner, array $attributes = []): Ticket
{
return Ticket::create(array_merge([
'uuid' => (string) Str::uuid(),
'ticket_num' => 'TKT-' . Str::upper(Str::random(6)),
'subject' => 'Subject',
'description' => 'Description',
'status' => 'open',
'room' => 'technical',
'customer_id' => $owner->id,
], $attributes));
}
private function as(Customer $customer): static
{
return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
private function list(array $query = [])
{
return $this->as($this->customer)->getJson('/v1/client/tickets?' . http_build_query($query));
}
public function test_paginates_with_requested_page_size(): void
{
foreach (range(1, 12) as $i) {
$this->makeTicket($this->customer, ['subject' => "Ticket {$i}"]);
}
$this->makeTicket(Customer::factory()->create(['status' => 1]));
$response = $this->list(['per_page' => 5, 'page' => 3])->assertOk();
$this->assertSame(12, $response->json('data.total'));
$this->assertSame(3, $response->json('data.last_page'));
$this->assertCount(2, $response->json('data.items'));
}
public function test_filters_by_search_room_and_status(): void
{
$this->makeTicket($this->customer, ['subject' => 'Lỗi VPS không khởi động', 'ticket_num' => 'TKT-VPS']);
$this->makeTicket($this->customer, ['subject' => 'Báo giá hosting', 'room' => 'sales']);
$this->makeTicket($this->customer, ['subject' => 'VPS chậm', 'status' => 'closed']);
$this->makeTicket($this->customer, ['subject' => '100% CPU']);
$this->assertSame(['TKT-VPS'], collect($this->list(['search' => 'khởi động'])->json('data.items'))->pluck('ticket_num')->all());
$this->assertCount(1, $this->list(['search' => 'tkt-vps'])->json('data.items'));
$this->assertCount(1, $this->list(['search' => '100%'])->json('data.items'));
$this->assertCount(1, $this->list(['room' => 'sales'])->json('data.items'));
$this->assertCount(1, $this->list(['search' => 'VPS', 'status' => 'open'])->json('data.items'));
}
public function test_rejects_oversized_page(): void
{
$this->list(['per_page' => 500])->assertStatus(422);
}
public function test_client_payload_hides_internal_ids_and_storage_urls(): void
{
Storage::fake('public');
Storage::disk('public')->put('tickets/legacy.webp', 'legacy');
$ticket = $this->makeTicket($this->customer, [
'ticket_num' => 'TKT-LEGACY',
'data' => json_encode(['priority' => 'high']),
'gallery' => ['https://api.portal.test/storage/tickets/legacy.webp'],
]);
TicketReply::create(['ticket_id' => $ticket->id, 'message' => 'Hi', 'user_id' => User::factory()->create()->id]);
$data = $this->as($this->customer)->getJson('/v1/client/tickets/TKT-LEGACY')->assertOk()->json('data');
foreach (['id', 'uuid', 'customer_id', 'data'] as $key) {
$this->assertArrayNotHasKey($key, $data);
}
$this->assertSame(['fullname'], array_keys($data['customer']));
$this->assertSame('high', $data['priority']);
$this->assertSame(['/v1/client/tickets/TKT-LEGACY/images/0'], $data['gallery']);
$this->assertSame(['message', 'sender_type', 'sender_name', 'sender_role', 'created_at'], array_keys($data['replies'][0]));
$this->assertStringNotContainsString('storage', json_encode($data));
$this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/0')->assertOk();
$this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/1')->assertNotFound();
}
public function test_uploaded_images_are_private_and_owner_only(): void
{
Storage::fake('local');
Mail::fake();
Queue::fake();
$ticketNum = $this->as($this->customer)->post('/v1/client/tickets', [
'subject' => 'Cần hỗ trợ VPS',
'description' => 'Mô tả chi tiết vấn đề đang gặp phải',
'images' => [UploadedFile::fake()->image('shot.png')],
])->assertCreated()->json('data.ticket_num');
$path = Ticket::where('ticket_num', $ticketNum)->value('gallery')[0];
$this->assertStringStartsWith("ticket-attachments/{$this->customer->id}/", $path);
Storage::disk('local')->assertExists($path);
$this->as($this->customer)->get("/v1/client/tickets/{$ticketNum}/images/0")->assertOk();
$this->as(Customer::factory()->create(['status' => 1]))->get("/v1/client/tickets/{$ticketNum}/images/0")->assertNotFound();
$this->get("/api/v1/administrator/tickets/{$ticketNum}/images/0")->assertForbidden();
$signed = URL::temporarySignedRoute('admin.tickets.image', now()->addMinute(), ['ticket' => $ticketNum, 'index' => 0]);
$this->get($signed)->assertOk();
}
public function test_moves_legacy_public_images_to_private_disk(): void
{
Storage::fake('public');
Storage::fake('local');
Storage::disk('public')->put('tickets/old.webp', 'old');
$ticket = $this->makeTicket($this->customer, ['gallery' => ['https://api.portal.test/storage/tickets/old.webp']]);
$this->assertTrue(TicketGallery::moveLegacyToPrivate($ticket));
$path = "ticket-attachments/{$this->customer->id}/old.webp";
$this->assertSame([$path], $ticket->fresh()->gallery);
Storage::disk('local')->assertExists($path);
Storage::disk('public')->assertMissing('tickets/old.webp');
$this->assertFalse(TicketGallery::moveLegacyToPrivate($ticket->fresh()));
}
public function test_ticket_emails_show_customer_fullname_and_room_label(): void
{
$this->seed(\Database\Seeders\TemplateSeeder::class);
$this->customer->update(['fullname' => 'Nguyễn Văn A']);
$ticket = $this->makeTicket($this->customer, ['ticket_num' => 'TKT-MAIL']);
foreach ([new TicketCreated($ticket, $this->customer), new TicketAdminNotification($ticket, $this->customer)] as $mail) {
$html = $mail->render();
$this->assertStringContainsString('Nguyễn Văn A', $html, $mail::class);
$this->assertStringContainsString('Kỹ thuật', $html, $mail::class);
$this->assertStringNotContainsString('N/A', $html, $mail::class);
}
}
}