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:
co-authored by
Claude Opus 5.5
parent
823af4a56a
commit
e888a6ed5d
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user