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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user