feat: Turnstile captcha, menu khách + nút tắt topbar cấu hình từ admin, ẩn setting nhạy cảm

- Turnstile (App\Support\Turnstile, middleware turnstile) cho login admin và login/register/forgot/reset của khách; cấu hình ở setting key=turnstile, secret mã hoá SignRSA.
- Menu sidebar khách (setting client_menu) và nút tắt topbar (setting client_quick_links) do admin sửa qua administrator/client-menu[/quick-links], khách đọc v1/setting/menu, v1/setting/quick-links.
- v1/setting và settings/public chỉ trả whitelist PublicSetting::FIELDS (trước đây lộ bot_token, inet_api_key).
- Cài đặt tài khoản khách (preferences), rate limit theo IP cho register/resend-verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-12 15:53:43 +07:00
co-authored by Claude Opus 5.5
parent 823af4a56a
commit e888a6ed5d
33 changed files with 1022 additions and 52 deletions
@@ -0,0 +1,89 @@
<?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 ClientMenuTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'setting.manager', 'guard_name' => 'admin']));
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
public function test_admin_saves_menu_and_client_reads_it(): void
{
$items = [
['title' => 'Dashboard', 'icon' => 'hugeicons:home-01', 'href' => '/'],
['title' => 'Dịch vụ', 'icon' => null, 'href' => null, 'children' => [
['title' => 'VPS', 'href' => '/dich-vu/vps', 'disabled' => true],
]],
];
$this->actingAs($this->admin(), 'admin')
->putJson('/api/v1/administrator/client-menu', ['items' => $items])
->assertOk();
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->getJson('/v1/setting/menu')
->assertOk()
->assertJsonPath('data.0.title', 'Dashboard')
->assertJsonPath('data.0.children', [])
->assertJsonPath('data.1.href', null)
->assertJsonPath('data.1.children.0.href', '/dich-vu/vps')
->assertJsonPath('data.1.children.0.disabled', true);
}
public function test_rejects_unsafe_href_and_leaf_without_href(): void
{
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->putJson('/api/v1/administrator/client-menu', ['items' => [
['title' => 'X', 'href' => 'javascript:alert(1)'],
['title' => 'Y', 'href' => '//evil.example'],
['title' => 'Z', 'href' => null],
['title' => 'W', 'href' => null, 'children' => [['title' => 'C', 'href' => 'data:text/html,x']]],
]])
->assertStatus(422)
->assertJsonValidationErrors(['items.0.href', 'items.1.href', 'items.2.href', 'items.3.children.0.href']);
}
public function test_admin_saves_quick_links_and_client_reads_them(): void
{
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->putJson('/api/v1/administrator/client-menu/quick-links', ['items' => [
['title' => 'Tạo Cloud VPS', 'icon' => 'lucide:cloud', 'href' => '/san-pham/vps'],
]])
->assertOk();
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->getJson('/v1/setting/quick-links')
->assertOk()
->assertJsonPath('data.0.title', 'Tạo Cloud VPS')
->assertJsonPath('data.0.disabled', false);
$this->actingAs($admin, 'admin')
->putJson('/api/v1/administrator/client-menu/quick-links', ['items' => [
['title' => 'X', 'href' => 'javascript:alert(1)'],
['title' => 'Y', 'href' => null],
]])
->assertStatus(422)
->assertJsonValidationErrors(['items.0.href', 'items.1.href']);
}
}
@@ -56,6 +56,7 @@ class PermissionGatingTest extends TestCase
{
return [
'settings index' => ['GET', '/api/v1/administrator/settings', 'setting.manager'],
'client-menu index' => ['GET', '/api/v1/administrator/client-menu', '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'],
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Models\ActivityLog;
use App\Models\Customer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ClientAccountSettingsTest extends TestCase
{
use RefreshDatabase;
private function api(): static
{
return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
public function test_customer_can_only_read_their_own_login_history(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$other = Customer::factory()->create(['status' => 1]);
ActivityLog::create([
'log_name' => 'auth',
'event' => 'login',
'description' => 'customer login',
'causer_type' => 'customer',
'causer_id' => $customer->id,
'causer_name' => $customer->email,
'ip_address' => '203.0.113.10',
'user_agent' => 'Mozilla/5.0 Chrome/130.0',
]);
ActivityLog::create([
'log_name' => 'auth',
'event' => 'login',
'description' => 'other customer login',
'causer_type' => 'customer',
'causer_id' => $other->id,
'causer_name' => $other->email,
'ip_address' => '198.51.100.20',
]);
$response = $this->actingAs($customer, 'client')
->api()
->getJson('/v1/client/login-history?per_page=10')
->assertOk()
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.ip_address', '203.0.113.10');
$this->assertCount(1, $response->json('data'));
}
public function test_customer_can_update_account_preferences(): void
{
$customer = Customer::factory()->create([
'status' => 1,
'email_marketing' => 1,
'receive_deposit' => 1,
]);
$this->actingAs($customer, 'client')
->api()
->putJson('/v1/client/preferences', [
'email_marketing' => false,
'receive_deposit' => true,
])
->assertOk()
->assertJsonPath('data.client.customer.email_marketing', false)
->assertJsonPath('data.client.customer.receive_deposit', true);
$customer->refresh();
$this->assertSame(0, (int) $customer->email_marketing);
$this->assertSame(1, (int) $customer->receive_deposit);
}
public function test_preferences_require_both_boolean_values(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$this->actingAs($customer, 'client')
->api()
->putJson('/v1/client/preferences', ['email_marketing' => 'yes'])
->assertStatus(422)
->assertJsonValidationErrors(['email_marketing', 'receive_deposit']);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublicSettingTest extends TestCase
{
use RefreshDatabase;
public function test_public_setting_endpoints_expose_only_whitelisted_fields(): void
{
Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode([
'title' => 'Portal',
'brand_color' => '#EF6824',
'bot_token' => 'secret-bot-token',
'inet_api_key' => 'secret-inet-key',
'telegram_group_id' => '-100123',
'admin_email' => 'admin@example.com',
])]);
$client = $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->getJson('/v1/setting')
->assertOk()
->assertJsonPath('data.title', 'Portal')
->assertJsonPath('data.brand_color', '#EF6824');
$admin = $this->getJson('/api/v1/settings/public')->assertOk();
foreach ([$client, $admin] as $response) {
$body = $response->getContent();
$this->assertStringNotContainsString('secret-bot-token', $body);
$this->assertStringNotContainsString('secret-inet-key', $body);
$this->assertStringNotContainsString('-100123', $body);
$this->assertStringNotContainsString('admin@example.com', $body);
}
}
}
@@ -30,13 +30,12 @@ class SecurityHardeningTest extends TestCase
{
$response = $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->postJson('/v1/client/register', [
'fullname' => 'Nguyen Van B',
'phone' => '0912345679',
'email' => 'long-pass@example.com',
'password' => 'abcdefghij',
'password_confirmation' => 'abcdefghij',
]);
$response->assertJsonMissingValidationErrors('password');
$response->assertJsonMissingValidationErrors(['password', 'phone']);
}
public function test_cccd_upload_rejects_svg(): void
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Support;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class RegisterThrottleTest extends TestCase
{
private function limitsFor(string $routeName): mixed
{
$route = Route::getRoutes()->getByName($routeName);
$request = Request::create('/' . $route->uri(), 'POST', ['email' => 'a@example.com'], [], [], ['REMOTE_ADDR' => '9.9.9.9']);
$request->setRouteResolver(fn () => $route);
return RateLimiter::limiter('client-auth')($request);
}
public function test_email_sending_routes_are_limited_per_ip_across_emails(): void
{
foreach (['api.client.register' => 5, 'api.client.resend-verify' => 10] as $name => $max) {
$limits = $this->limitsFor($name);
$this->assertCount(2, $limits);
$this->assertSame($name . '|ip|9.9.9.9', $limits[1]->key);
$this->assertSame($max, $limits[1]->maxAttempts);
}
}
public function test_other_auth_routes_keep_single_email_limit(): void
{
$this->assertInstanceOf(\Illuminate\Cache\RateLimiting\Limit::class, $this->limitsFor('api.client.login'));
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace Tests\Feature\Support;
use App\Http\Facade\SignRSA;
use App\Models\Setting;
use App\Support\Turnstile;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use Tests\TestCase;
class TurnstileTest extends TestCase
{
use DatabaseTransactions;
private function configure(bool $enabled): void
{
Setting::updateOrCreate(['key' => Turnstile::SETTING_KEY], ['value' => json_encode([
'enabled' => $enabled,
'site_key' => 'site',
'secret_key' => SignRSA::EncryptFunc('secret'),
])]);
}
public function test_disabled_passes_without_token(): void
{
$this->configure(false);
Http::fake();
$this->assertNull(Turnstile::siteKey());
$this->assertTrue(Turnstile::verify(null, null));
Http::assertNothingSent();
}
public function test_enabled_requires_valid_token(): void
{
$this->configure(true);
Http::fake(['challenges.cloudflare.com/*' => Http::sequence()
->push(['success' => true])
->push(['success' => false, 'error-codes' => ['timeout-or-duplicate']])]);
$this->assertSame('site', Turnstile::siteKey());
$this->assertFalse(Turnstile::verify('', '1.1.1.1'));
$this->assertTrue(Turnstile::verify('ok', '1.1.1.1'));
$this->assertFalse(Turnstile::verify('reused', '1.1.1.1'));
Http::assertSent(fn ($request) => $request['secret'] === 'secret' && $request['response'] === 'ok');
}
public function test_save_keeps_masked_secret_and_rejects_enabling_without_keys(): void
{
$this->configure(false);
Turnstile::save(['enabled' => true, 'site_key' => 'new-site', 'secret_key' => Turnstile::SECRET_MASK]);
$this->assertSame('secret', SignRSA::DecryptFunc(Turnstile::stored()['secret_key']));
$this->assertSame(['enabled' => true, 'site_key' => 'new-site', 'secret_key' => Turnstile::SECRET_MASK], Turnstile::forAdmin());
$this->expectException(InvalidArgumentException::class);
Turnstile::save(['secret_key' => '']);
}
public function test_admin_login_rejects_missing_token_when_enabled(): void
{
$this->configure(true);
Http::fake();
$this->postJson('/api/v1/administrator/login', ['username' => 'x', 'password' => 'y'])
->assertStatus(422)
->assertJsonPath('error', true);
Http::assertNothingSent();
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Http\Resources\Api\Admin\ProductResource;
use App\Models\Product;
use App\Models\ProductPricings;
use Illuminate\Support\Collection;
use Tests\TestCase;
class AdminProductResourceTest extends TestCase
{
public function test_it_reads_legacy_pricing_cycle_names_without_returning_null(): void
{
$product = new Product([
'name' => 'Legacy product',
'is_active' => true,
'setup_fee' => 0,
]);
$product->id = 4;
$product->setRelation('pricings', new Collection([
new ProductPricings([
'billing_cycle' => 'semiannually',
'price' => 7200000,
'currency' => 'VND',
]),
new ProductPricings([
'billing_cycle' => 'one_time',
'price' => 15000000,
'currency' => 'VND',
]),
]));
$data = (new ProductResource($product))->resolve(request());
$this->assertSame('7200000.00', $data['price_semiannually']);
$this->assertSame('15000000.00', $data['price_onetime']);
$this->assertSame('recurring', $data['billing_type']);
}
public function test_legacy_one_time_only_product_uses_frontend_billing_value(): void
{
$product = new Product([
'name' => 'One-time product',
'is_active' => true,
'setup_fee' => 0,
]);
$product->id = 5;
$product->setRelation('pricings', new Collection([
new ProductPricings([
'billing_cycle' => 'one_time',
'price' => 15000000,
'currency' => 'VND',
]),
]));
$data = (new ProductResource($product))->resolve(request());
$this->assertSame('onetime', $data['billing_type']);
$this->assertSame('15000000.00', $data['price_onetime']);
}
}