feat: add domain registration form and email notifications
- Create a new Blade view for domain registration (`registrant-tochuc.blade.php`) to capture organization and manager details. - Implement email templates for new domain orders for both admin (`domain-order-new.blade.php`) and customers (`domain-order-received.blade.php`). - Add CSV exports for client and domain data. - Introduce tests for customer mass assignment, password reset, login throttling, and permission gating. - Ensure service renewals use current pricing and validate ownership for virtual machines. - Validate mass assignment behavior in service model.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Customer;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CustomerMassAssignmentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
}
|
||||
|
||||
public function test_store_ignores_fields_not_declared_in_validation_rules(): void
|
||||
{
|
||||
$response = $this->actingAs($this->admin, 'admin')->postJson('/api/v1/administrator/customers', [
|
||||
'fullname' => 'Nguyen Van A',
|
||||
'email' => 'nva@example.com',
|
||||
'password' => 'secret123',
|
||||
// Các field này nằm trong $fillable của Customer nhưng KHÔNG có trong
|
||||
// CreateCustomerRequest::rules() — không được phép set qua endpoint tạo mới.
|
||||
'credit' => 999_999_999,
|
||||
'total' => 999_999_999,
|
||||
'status' => 1,
|
||||
'email_verified_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$customer = Customer::where('email', 'nva@example.com')->firstOrFail();
|
||||
$this->assertSame(0, (int) $customer->credit);
|
||||
$this->assertSame(0, (int) $customer->total);
|
||||
$this->assertNull($customer->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_update_ignores_fields_not_declared_in_validation_rules(): void
|
||||
{
|
||||
$customer = Customer::factory()->create([
|
||||
'credit' => 0,
|
||||
'total' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->admin, 'admin')->putJson(
|
||||
"/api/v1/administrator/customers/{$customer->uuid}",
|
||||
[
|
||||
'fullname' => 'Updated Name',
|
||||
'email' => $customer->email,
|
||||
'credit' => 999_999_999,
|
||||
'total' => 999_999_999,
|
||||
],
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(0, (int) $customer->credit);
|
||||
$this->assertSame(0, (int) $customer->total);
|
||||
$this->assertSame('Updated Name', $customer->fullname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Customer;
|
||||
|
||||
use App\Mail\Customer\PasswordResetByAdmin;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResetPasswordTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
private Customer $customer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
|
||||
$this->customer = Customer::factory()->create([
|
||||
'password' => Hash::make('old-password'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_reset_password_sends_email_and_does_not_leak_password_in_response(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$oldHash = $this->customer->password;
|
||||
|
||||
$response = $this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/reset-password");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonMissingPath('data.new_password');
|
||||
$response->assertJsonMissing(['new_password']);
|
||||
|
||||
Mail::assertSent(PasswordResetByAdmin::class, function (PasswordResetByAdmin $mail) {
|
||||
return $mail->hasTo($this->customer->email);
|
||||
});
|
||||
|
||||
$this->assertNotSame($oldHash, $this->customer->fresh()->password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvoiceTestRouteRemovedTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_unauthenticated_invoice_test_routes_no_longer_exist(): void
|
||||
{
|
||||
$this->getJson('/api/v1/invoice-test/INV-000001/preview-pdf')->assertStatus(404);
|
||||
$this->postJson('/api/v1/invoice-test/INV-000001/send-email')->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginThrottleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Đảm bảo state rate-limit sạch, không bị ảnh hưởng bởi request từ test khác
|
||||
// chạy cùng tiến trình PHPUnit (cache 'array' không tự reset giữa các test file).
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_admin_login_is_throttled_after_5_attempts_per_minute(): void
|
||||
{
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$response = $this->postJson('/api/v1/administrator/login', [
|
||||
'email' => 'nobody@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
$this->assertNotSame(429, $response->status());
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/v1/administrator/login', [
|
||||
'email' => 'nobody@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(429);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PermissionGatingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// CheckPermission throws when the checked permission row doesn't exist at all —
|
||||
// seed every permission this test touches so unauthorized checks 403 instead of 500.
|
||||
foreach (self::gatedRoutesProvider() as [, , $permission]) {
|
||||
Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin']);
|
||||
}
|
||||
Permission::firstOrCreate(['name' => 'some-other.manager', 'guard_name' => 'admin']);
|
||||
}
|
||||
|
||||
private function makeAdminWithPermissions(array $permissions): User
|
||||
{
|
||||
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
|
||||
foreach ($permissions as $permission) {
|
||||
$role->givePermissionTo(
|
||||
Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin']),
|
||||
);
|
||||
}
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole($role);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/** @dataProvider gatedRoutesProvider */
|
||||
public function test_route_requires_expected_permission(string $method, string $uri, string $requiredPermission): void
|
||||
{
|
||||
$unauthorized = $this->makeAdminWithPermissions(['some-other.manager']);
|
||||
$this->actingAs($unauthorized, 'admin')->json($method, $uri)->assertStatus(403);
|
||||
|
||||
$authorized = $this->makeAdminWithPermissions([$requiredPermission]);
|
||||
$response = $this->actingAs($authorized, 'admin')->json($method, $uri);
|
||||
$this->assertNotSame(403, $response->status(), "Expected {$uri} to be accessible with '{$requiredPermission}', got 403.");
|
||||
}
|
||||
|
||||
public static function gatedRoutesProvider(): array
|
||||
{
|
||||
return [
|
||||
'settings index' => ['GET', '/api/v1/administrator/settings', 'setting.manager'],
|
||||
'banks index' => ['GET', '/api/v1/administrator/banks', 'setting.manager'],
|
||||
'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'],
|
||||
'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'],
|
||||
'notification-templates' => ['GET', '/api/v1/administrator/notification-templates', 'email-template.manager'],
|
||||
'tickets index' => ['GET', '/api/v1/administrator/tickets', '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'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_full_administrator_bypasses_all_permission_gates(): void
|
||||
{
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
foreach (self::gatedRoutesProvider() as [$method, $uri]) {
|
||||
$response = $this->actingAs($admin, 'admin')->json($method, $uri);
|
||||
$this->assertNotSame(403, $response->status(), "Full Administrator should not be blocked from {$uri}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Service;
|
||||
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPricings;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RenewServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Customer $customer;
|
||||
private Product $product;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->customer = Customer::factory()->create();
|
||||
$this->product = Product::factory()->create([
|
||||
'product_type' => 'hosting',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function actingAsCustomer(): static
|
||||
{
|
||||
return $this->actingAs($this->customer, 'client')
|
||||
->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
|
||||
}
|
||||
|
||||
/** Encrypts the shared secret the same way ClientAccessTokenCheck expects, for test requests. */
|
||||
private function buildClientAccessToken(): string
|
||||
{
|
||||
$key = hex2bin((string) config('services.client_api.key'));
|
||||
$secret = (string) config('services.client_api.secret');
|
||||
$iv = random_bytes(16);
|
||||
$ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return base64_encode($iv . $ciphertext);
|
||||
}
|
||||
|
||||
private function createServiceWithOldPrice(int $oldPrice): Service
|
||||
{
|
||||
$order = Order::create([
|
||||
'order_num' => 'ORD-' . uniqid(),
|
||||
'customer_id' => $this->customer->id,
|
||||
'sub_total' => $oldPrice,
|
||||
'total' => $oldPrice,
|
||||
'status' => OrderStatus::ACTIVE->value,
|
||||
]);
|
||||
|
||||
$orderItem = OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'months' => 1,
|
||||
'quantity' => 1,
|
||||
'price' => $oldPrice,
|
||||
]);
|
||||
|
||||
return Service::create([
|
||||
'customer_id' => $this->customer->id,
|
||||
'product_id' => $this->product->id,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'status' => 'active',
|
||||
'server_name' => 'test-server',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_renew_uses_current_catalog_price_not_the_original_order_price(): void
|
||||
{
|
||||
$service = $this->createServiceWithOldPrice(90_000);
|
||||
|
||||
// Giá sản phẩm hiện tại đã tăng lên 100.000 kể từ lúc khách đặt mua.
|
||||
ProductPricings::create([
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'price' => 100_000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 2],
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.total', 200_000);
|
||||
|
||||
$this->assertDatabaseHas('invoices', [
|
||||
'id' => $response->json('data.invoice_id'),
|
||||
'total' => 200_000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_renew_fails_when_product_pricing_is_no_longer_active(): void
|
||||
{
|
||||
$service = $this->createServiceWithOldPrice(90_000);
|
||||
|
||||
ProductPricings::create([
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'price' => 100_000,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Service;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\ModuleVmware;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VmOwnershipTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function buildClientAccessToken(): string
|
||||
{
|
||||
$key = hex2bin((string) config('services.client_api.key'));
|
||||
$secret = (string) config('services.client_api.secret');
|
||||
$iv = random_bytes(16);
|
||||
$ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return base64_encode($iv . $ciphertext);
|
||||
}
|
||||
|
||||
public function test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id(): void
|
||||
{
|
||||
$owner = Customer::factory()->create();
|
||||
$attacker = Customer::factory()->create();
|
||||
|
||||
$vm = ModuleVmware::create([
|
||||
'name' => 'owner-vm',
|
||||
'customer_id' => $owner->id,
|
||||
'vmware_id' => 'vm-1001',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($attacker, 'client')
|
||||
->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->getJson('/v1/system/vm-power-state?product=' . $vm->id);
|
||||
|
||||
// Query đã lọc theo customer_id nên VM của người khác không được tìm thấy (404)
|
||||
// thay vì tìm thấy rồi mới bị chặn bởi check quyền sở hữu phía sau (403) — cả hai đều
|
||||
// an toàn, nhưng 404 xác nhận query đã được scope đúng ngay từ đầu (defense-in-depth).
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServiceFillableTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_service_ignores_fields_not_declared_in_fillable(): void
|
||||
{
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
$service = Service::create([
|
||||
'customer_id' => $customer->id,
|
||||
'server_name' => 'test-server',
|
||||
'status' => 'active',
|
||||
// Không nằm trong $fillable — không được set qua mass-assignment.
|
||||
'legacy_id' => 999999,
|
||||
]);
|
||||
|
||||
$this->assertSame('test-server', $service->server_name);
|
||||
$this->assertNull($service->legacy_id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user