- PUT v1/client/update: gender theo enum ClientGender (0 Nam/1 Nữ), thêm
birthday (cột mới), province_code/ward_code validate theo bảng provinces/wards
(phường phải thuộc tỉnh). Migration nạp dữ liệu hành chính 2025 từ
province_ward_import.sql (chỉ bảng trống), API GET v1/provinces[/{code}/wards].
- CustomerObserver/OrderObserver log giá trị cũ bằng getRawOriginal: getOriginal
trả enum object, nối chuỗi gây 500 khi khách đổi giới tính.
- ClientResource: các cờ trả boolean, CCCD chỉ trả cccd_updated (không lộ ảnh/số).
- GET v1/setting/menu trả cả items + quick_links, bỏ v1/setting/quick-links.
- Thông báo admin/khách tra theo uuid thay vì id tự tăng.
- Chặn gửi template marketing cho khách đã tắt nhận email marketing.
- Xoá ProvinceWardSeeder và Customer::$updatable không dùng.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
12 KiB
PHP
211 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\Client;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Models\Customer;
|
|
use App\Models\DnsDomain;
|
|
use App\Models\DnsManagerNS;
|
|
use App\Models\Invoice;
|
|
use App\Models\ModuleVmware;
|
|
use App\Models\Notification;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Product;
|
|
use App\Models\ProductPricings;
|
|
use App\Models\Service;
|
|
use App\Models\Ticket;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class ClientTamperingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Customer $victim;
|
|
private Customer $attacker;
|
|
private Order $order;
|
|
private Invoice $invoice;
|
|
private Service $hosting;
|
|
private Service $vps;
|
|
private ModuleVmware $vm;
|
|
private Product $product;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->victim = Customer::factory()->create(['status' => 1, 'total' => 500_000]);
|
|
$this->attacker = Customer::factory()->create(['status' => 1, 'total' => 10_000_000]);
|
|
|
|
$this->product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]);
|
|
ProductPricings::create(['product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 150_000, 'is_active' => true]);
|
|
|
|
$this->order = Order::create(['order_num' => 'ORD-VICTIM', 'customer_id' => $this->victim->id, 'total' => 150_000, 'status' => 0]);
|
|
$orderItem = OrderItem::create([
|
|
'order_id' => $this->order->id, 'product_id' => $this->product->id, 'billing_cycle' => 'monthly',
|
|
'months' => 1, 'quantity' => 1, 'price' => 150_000, 'addons_meta' => [],
|
|
]);
|
|
$this->invoice = Invoice::create([
|
|
'invoice_number' => 'INV-VICTIM', 'customer_id' => $this->victim->id, 'order_id' => $this->order->id,
|
|
'invoice_type' => 1, 'sub_total' => 150_000, 'tax' => 0, 'total' => 150_000,
|
|
'status' => InvoiceStatus::UNPAID->value,
|
|
]);
|
|
|
|
$this->hosting = Service::create([
|
|
'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'order_item_id' => $orderItem->id,
|
|
'type' => 'hosting', 'status' => 'active', 'started_at' => now()->subMonth(), 'expires_at' => now()->addDays(5),
|
|
]);
|
|
|
|
$this->vm = ModuleVmware::create(['customer_id' => $this->victim->id, 'vmware_id' => 987654]);
|
|
$this->vps = Service::create([
|
|
'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'type' => 'vps',
|
|
'status' => 'active', 'vm_id' => $this->vm->id, 'started_at' => now(), 'expires_at' => now()->addMonth(),
|
|
]);
|
|
}
|
|
|
|
private function as(Customer $customer): static
|
|
{
|
|
return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
|
|
}
|
|
|
|
private function assertDenied($response): void
|
|
{
|
|
$this->assertContains($response->status(), [403, 404, 422], "Expected denial, got {$response->status()}: " . $response->getContent());
|
|
}
|
|
|
|
public function test_profile_endpoints_ignore_balance_status_and_email_fields(): void
|
|
{
|
|
$payload = [
|
|
'fullname' => 'Attacker', 'phone' => '0900000000',
|
|
'credit' => 999_999_999, 'total' => 999_999_999, 'total_pending' => 0, 'point' => 999_999,
|
|
'status' => 1, 'email' => 'victim-takeover@example.com', 'two_factor_enabled' => false, 'id' => $this->victim->id,
|
|
];
|
|
|
|
$this->as($this->attacker)->putJson('/v1/client/update', $payload)->assertOk();
|
|
$this->as($this->attacker)->putJson('/v1/client/preferences', ['email_marketing' => true, 'receive_deposit' => true] + $payload)->assertOk();
|
|
|
|
$attacker = $this->attacker->fresh();
|
|
$this->assertSame(10_000_000, (int) $attacker->total);
|
|
$this->assertNotSame('victim-takeover@example.com', $attacker->email);
|
|
$this->assertSame('Attacker', $attacker->fullname);
|
|
$this->assertSame(500_000, (int) $this->victim->fresh()->total);
|
|
$this->assertNotSame('Attacker', $this->victim->fresh()->fullname);
|
|
}
|
|
|
|
public function test_cannot_read_another_customers_orders_invoices_or_transactions(): void
|
|
{
|
|
foreach ([
|
|
"/v1/client/orders/{$this->order->id}",
|
|
"/v1/client/orders/num/{$this->order->order_num}",
|
|
"/v1/client/invoices/{$this->invoice->id}",
|
|
"/v1/client/invoices/number/{$this->invoice->invoice_number}",
|
|
"/v1/client/invoices/order/{$this->order->order_num}",
|
|
"/v1/client/invoices/order/{$this->order->order_num}/pdf",
|
|
'/v1/client/payment-history/' . Str::uuid(),
|
|
] as $url) {
|
|
$this->assertDenied($this->as($this->attacker)->getJson($url));
|
|
}
|
|
}
|
|
|
|
public function test_cannot_pay_retry_or_renew_with_another_customers_resources(): void
|
|
{
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/invoices/number/{$this->invoice->invoice_number}/pay", ['payment_method' => 'balance']));
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/orders/{$this->order->order_num}/pay", ['payment_method' => 'balance']));
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/orders/{$this->order->id}/retry"));
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/dich-vu/{$this->hosting->id}/renew", ['months' => 1]));
|
|
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/dich-vu/{$this->hosting->id}/auto-renew", ['auto_renew' => true]));
|
|
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/client-service/{$this->hosting->uuid}/auto-renew", ['auto_renew' => true]));
|
|
|
|
$this->assertSame(InvoiceStatus::UNPAID->value, (int) $this->invoice->fresh()->status);
|
|
$this->assertSame(10_000_000, (int) $this->attacker->fresh()->total);
|
|
$this->assertSame(500_000, (int) $this->victim->fresh()->total);
|
|
$this->assertTrue($this->hosting->fresh()->expires_at->lt(now()->addDays(6)));
|
|
$this->assertFalse((bool) $this->hosting->fresh()->auto_renew);
|
|
}
|
|
|
|
public function test_cannot_view_or_operate_another_customers_vm_hosting_or_service(): void
|
|
{
|
|
foreach (["/v1/client/client-service/{$this->hosting->uuid}", "/v1/client/service/{$this->hosting->uuid}",
|
|
"/v1/client/client-service/{$this->vps->uuid}/available-os", "/v1/client/client-service/{$this->vps->uuid}/console-ticket"] as $url) {
|
|
$this->assertDenied($this->as($this->attacker)->getJson($url));
|
|
}
|
|
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/client-service/{$this->vps->uuid}/rebuild", ['os_id' => 1, 'password' => 'Secret12345!']));
|
|
|
|
foreach (['vm-power-off', 'vm-power-on', 'vm-restart', 'vm-change-password', 'vm-change-port', 'vm-change-ip', 'vm-create-snapshot', 'vm-rebuild', 'vm-upgrade-addons'] as $action) {
|
|
foreach ([$this->vm->id, $this->vm->vmware_id, $this->vps->uuid] as $id) {
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/system/{$action}", [
|
|
'product_id' => $id, 'id' => $id, 'vm_id' => $id, 'password' => 'Secret12345!', 'port' => 2222, 'name' => 'x',
|
|
]));
|
|
}
|
|
}
|
|
}
|
|
|
|
public function test_cannot_touch_another_customers_dns_zone(): void
|
|
{
|
|
$serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']);
|
|
$zone = DnsDomain::create(['domain_name' => 'victim.vn', 'dns_manager_server_id' => $serverId, 'is_created' => 1]);
|
|
$zone->customer()->attach($this->victim->id);
|
|
$record = DnsManagerNS::create(['dns_manager_domain_id' => $zone->id, 'type' => 'A', 'type_valid' => 'A', 'name' => 'victim.vn', 'value' => '1.1.1.1', 'ttl' => 3600]);
|
|
|
|
$base = "/v1/client/client-service/domains/{$zone->id}";
|
|
$this->assertDenied($this->as($this->attacker)->getJson($base));
|
|
$this->assertDenied($this->as($this->attacker)->getJson("{$base}/records"));
|
|
$this->assertDenied($this->as($this->attacker)->postJson("{$base}/records", ['type' => 'A', 'name' => 'evil', 'value' => '6.6.6.6', 'ttl' => 300]));
|
|
$this->assertDenied($this->as($this->attacker)->putJson("{$base}/records/{$record->id}", ['type' => 'A', 'name' => 'victim.vn', 'value' => '6.6.6.6', 'ttl' => 300]));
|
|
$this->assertDenied($this->as($this->attacker)->deleteJson("{$base}/records/{$record->id}"));
|
|
|
|
$this->assertSame('1.1.1.1', $record->fresh()?->value);
|
|
$this->assertSame(1, DnsManagerNS::where('dns_manager_domain_id', $zone->id)->count());
|
|
}
|
|
|
|
public function test_cannot_read_reply_or_close_another_customers_ticket_or_notification(): void
|
|
{
|
|
$ticket = Ticket::create(['uuid' => (string) Str::uuid(), 'ticket_num' => 'TK-VICTIM', 'subject' => 'S', 'customer_id' => $this->victim->id, 'description' => 'D', 'status' => 'open']);
|
|
$notification = Notification::create(['recipient_type' => 'client', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]);
|
|
|
|
$this->assertDenied($this->as($this->attacker)->getJson("/v1/client/tickets/{$ticket->ticket_num}"));
|
|
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/tickets/{$ticket->ticket_num}/reply", ['message' => 'pwned', 'content' => 'pwned']));
|
|
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/tickets/{$ticket->ticket_num}/close"));
|
|
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->uuid}/read"));
|
|
$this->assertDenied($this->as($this->attacker)->getJson("/v1/client/notifications/{$notification->uuid}"));
|
|
$own = $this->as($this->victim)->getJson("/v1/client/notifications/{$notification->uuid}")->assertOk()->json('data');
|
|
$this->assertSame($notification->uuid, $own['uuid']);
|
|
$this->assertArrayNotHasKey('id', $own);
|
|
|
|
$this->assertSame('open', $ticket->fresh()->status);
|
|
$this->assertFalse((bool) $notification->fresh()->is_read);
|
|
}
|
|
|
|
public function test_cannot_confirm_cancel_or_read_another_customers_deposit_code(): void
|
|
{
|
|
$uuid = $this->as($this->victim)->postJson('/v1/transaction/manual-deposit/prepare', ['amount' => 100_000, 'bank_id' => null])->json('data.uuid');
|
|
|
|
if (!$uuid) {
|
|
$this->markTestSkipped('prepare requires bank setup');
|
|
}
|
|
|
|
$this->assertDenied($this->as($this->attacker)->getJson("/v1/transaction/manual-deposit/{$uuid}"));
|
|
$this->assertDenied($this->as($this->attacker)->deleteJson("/v1/transaction/manual-deposit/{$uuid}/cancel"));
|
|
$this->assertDenied($this->as($this->attacker)->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $uuid, 'proof_image' => 'deposit-proofs/x.png', 'amount' => 999_999_999]));
|
|
$this->assertSame(500_000, (int) $this->victim->fresh()->total);
|
|
}
|
|
|
|
public function test_order_price_is_computed_server_side_and_ignores_client_amounts(): void
|
|
{
|
|
$response = $this->as($this->attacker)->postJson('/v1/orders', [
|
|
'product_id' => $this->product->uuid, 'billing_cycle' => 'monthly', 'server_name' => 'attacker-site.vn',
|
|
'price' => 1, 'total' => 1, 'amount' => 1, 'sub_total' => 1, 'discount' => 999_999,
|
|
]);
|
|
|
|
$order = Order::where('customer_id', $this->attacker->id)->latest('id')->first();
|
|
$this->assertNotNull($order, 'order not created: ' . $response->getContent());
|
|
$this->assertSame(150_000, (int) $order->total);
|
|
}
|
|
}
|