From e888a6ed5dc2b842fb218a24c5a2d81b3eb0fc87 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Mon, 12 Oct 2026 15:53:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Turnstile=20captcha,=20menu=20kh=C3=A1c?= =?UTF-8?q?h=20+=20n=C3=BAt=20t=E1=BA=AFt=20topbar=20c=E1=BA=A5u=20h=C3=AC?= =?UTF-8?q?nh=20t=E1=BB=AB=20admin,=20=E1=BA=A9n=20setting=20nh=E1=BA=A1y?= =?UTF-8?q?=20c=E1=BA=A3m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/deploy.yml | 6 + app/Console/Commands/ReencryptSecrets.php | 19 +-- .../Api/Admin/Product/ProductController.php | 21 ++-- .../Admin/Setting/ClientMenuController.php | 46 +++++++ .../Api/Admin/Setting/SettingController.php | 26 ++-- .../Api/Client/ClientController.php | 2 + .../Concerns/ManagesClientAccountSettings.php | 72 +++++++++++ .../Api/Setting/SettingController.php | 37 +++++- app/Http/Kernel.php | 1 + app/Http/Middleware/VerifyTurnstile.php | 25 ++++ .../Api/Client/ClientPreferencesRequest.php | 21 ++++ .../Api/Client/ClientRegisterRequest.php | 3 +- .../Resources/Api/Admin/ProductResource.php | 14 +-- .../Resources/Api/Client/ClientResource.php | 2 + app/Providers/RouteServiceProvider.php | 13 +- app/Services/Api/Client/ClientAuthService.php | 2 +- app/Support/ClientMenu.php | 75 ++++++++++++ app/Support/ClientQuickLinks.php | 49 ++++++++ app/Support/PublicSetting.php | 20 ++++ app/Support/Turnstile.php | 113 ++++++++++++++++++ ..._10_12_000001_seed_client_menu_setting.php | 55 +++++++++ ...000002_seed_client_quick_links_setting.php | 30 +++++ routes/api.php | 2 +- routes/api/admin/system.php | 11 ++ routes/client.php | 13 +- tests/Feature/Api/Admin/ClientMenuTest.php | 89 ++++++++++++++ .../Api/Admin/PermissionGatingTest.php | 1 + .../Api/Client/ClientAccountSettingsTest.php | 89 ++++++++++++++ .../Feature/Api/Client/PublicSettingTest.php | 42 +++++++ .../Api/Client/SecurityHardeningTest.php | 3 +- .../Feature/Support/RegisterThrottleTest.php | 36 ++++++ tests/Feature/Support/TurnstileTest.php | 72 +++++++++++ tests/Unit/AdminProductResourceTest.php | 64 ++++++++++ 33 files changed, 1022 insertions(+), 52 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/Setting/ClientMenuController.php create mode 100644 app/Http/Controllers/Api/Client/Concerns/ManagesClientAccountSettings.php create mode 100644 app/Http/Middleware/VerifyTurnstile.php create mode 100644 app/Http/Requests/Api/Client/ClientPreferencesRequest.php create mode 100644 app/Support/ClientMenu.php create mode 100644 app/Support/ClientQuickLinks.php create mode 100644 app/Support/PublicSetting.php create mode 100644 app/Support/Turnstile.php create mode 100644 database/migrations/2026_10_12_000001_seed_client_menu_setting.php create mode 100644 database/migrations/2026_10_12_000002_seed_client_quick_links_setting.php create mode 100644 tests/Feature/Api/Admin/ClientMenuTest.php create mode 100644 tests/Feature/Api/Client/ClientAccountSettingsTest.php create mode 100644 tests/Feature/Api/Client/PublicSettingTest.php create mode 100644 tests/Feature/Support/RegisterThrottleTest.php create mode 100644 tests/Feature/Support/TurnstileTest.php create mode 100644 tests/Unit/AdminProductResourceTest.php diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8f10511..ff5548b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,10 +29,16 @@ jobs: git fetch origin production git reset --hard origin/production + echo "==> Installing dependencies..." + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + echo "==> Running migrations..." php artisan migrate --force echo "==> Optimizing Laravel..." php artisan optimize + echo "==> Restarting queue workers..." + php artisan queue:restart + echo "==> Deploy completed!" diff --git a/app/Console/Commands/ReencryptSecrets.php b/app/Console/Commands/ReencryptSecrets.php index 81d1956..dbef0fc 100644 --- a/app/Console/Commands/ReencryptSecrets.php +++ b/app/Console/Commands/ReencryptSecrets.php @@ -47,7 +47,8 @@ class ReencryptSecrets extends Command $failed += $this->reencryptColumn($table, $column, fn (string $value) => Crypt::encryptString(Crypt::decryptString($value))); } - $failed += $this->reencryptSmtpSetting(); + $failed += $this->reencryptJsonSetting('smtp', 'password'); + $failed += $this->reencryptJsonSetting('turnstile', 'secret_key'); return $failed === 0 ? self::SUCCESS : self::FAILURE; } @@ -89,27 +90,27 @@ class ReencryptSecrets extends Command return $failed; } - private function reencryptSmtpSetting(): int + private function reencryptJsonSetting(string $key, string $field): int { - $setting = DB::table('setting')->where('key', 'smtp')->first(); - $smtp = $setting ? json_decode((string) $setting->value, true) : null; + $setting = DB::table('setting')->where('key', $key)->first(); + $value = $setting ? json_decode((string) $setting->value, true) : null; - if (empty($smtp['password'])) { + if (empty($value[$field])) { return 0; } try { - $smtp['password'] = SignRSA::EncryptFunc($this->decryptRsa($smtp['password'])); + $value[$field] = SignRSA::EncryptFunc($this->decryptRsa($value[$field])); } catch (Throwable) { - $this->warn('setting[smtp].password: cannot decrypt with current or previous key'); + $this->warn("setting[{$key}].{$field}: cannot decrypt with current or previous key"); return 1; } if (!$this->option('dry-run')) { - DB::table('setting')->where('key', 'smtp')->update(['value' => json_encode($smtp)]); + DB::table('setting')->where('key', $key)->update(['value' => json_encode($value)]); } - $this->info('setting[smtp].password: re-encrypted'); + $this->info("setting[{$key}].{$field}: re-encrypted"); return 0; } diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index b1e4682..e9fe766 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -205,16 +205,16 @@ class ProductController extends Controller { $currency = $data['currency'] ?? 'VND'; $cycles = [ - 'monthly' => 'price_monthly', - 'quarterly' => 'price_quarterly', - 'semi_annually' => 'price_semiannually', - 'annually' => 'price_annually', - 'biennially' => 'price_biennially', - 'triennially' => 'price_triennially', - 'onetime' => 'price_onetime', + 'monthly' => ['price_monthly', []], + 'quarterly' => ['price_quarterly', []], + 'semi_annually' => ['price_semiannually', ['semiannually']], + 'annually' => ['price_annually', []], + 'biennially' => ['price_biennially', []], + 'triennially' => ['price_triennially', []], + 'onetime' => ['price_onetime', ['one_time']], ]; - foreach ($cycles as $cycle => $field) { + foreach ($cycles as $cycle => [$field, $legacyCycles]) { if (!array_key_exists($field, $data)) { continue; } @@ -226,9 +226,12 @@ class ProductController extends Controller ['product_id' => $product->id, 'billing_cycle' => $cycle], ['price' => $price, 'currency' => $currency, 'is_active' => true] ); + ProductPricings::where('product_id', $product->id) + ->whereIn('billing_cycle', $legacyCycles) + ->delete(); } else { ProductPricings::where('product_id', $product->id) - ->where('billing_cycle', $cycle) + ->whereIn('billing_cycle', [$cycle, ...$legacyCycles]) ->delete(); } } diff --git a/app/Http/Controllers/Api/Admin/Setting/ClientMenuController.php b/app/Http/Controllers/Api/Admin/Setting/ClientMenuController.php new file mode 100644 index 0000000..77ee718 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Setting/ClientMenuController.php @@ -0,0 +1,46 @@ +json(['error' => false, 'data' => ClientMenu::items()]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate(ClientMenu::rules()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu menu.', + 'data' => ClientMenu::save($validated['items']), + ]); + } + + public function showQuickLinks(): JsonResponse + { + return response()->json(['error' => false, 'data' => ClientQuickLinks::items()]); + } + + public function updateQuickLinks(Request $request): JsonResponse + { + $validated = $request->validate(ClientQuickLinks::rules()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu nút tắt.', + 'data' => ClientQuickLinks::save($validated['items']), + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 52c188f..7c068cf 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -9,6 +9,8 @@ use App\Http\Facade\SignRSA; use App\Http\Services\Telegram; use App\Models\Setting; use App\Support\Cache\CacheKey; +use App\Support\PublicSetting; +use App\Support\Turnstile; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -39,6 +41,7 @@ class SettingController extends Controller 'data' => [ 'setting' => $settingValue, 'smtp' => $smtpValue, + 'turnstile' => Turnstile::forAdmin(), ], ]); } @@ -49,18 +52,9 @@ class SettingController extends Controller */ public function public(): JsonResponse { - $setting = Setting::where('key', 'setting')->first(); - $value = $setting ? json_decode((string) $setting->value, true) : []; - return response()->json([ 'error' => false, - 'data' => [ - 'title' => $value['title'] ?? '', - 'company' => $value['company'] ?? '', - 'logo' => $value['logo'] ?? '', - 'favi_icon' => $value['favi_icon'] ?? '', - 'brand_color' => $value['brand_color'] ?? '', - ], + 'data' => PublicSetting::values() + ['turnstile_site_key' => Turnstile::siteKey()], ]); } @@ -80,6 +74,10 @@ class SettingController extends Controller 'smtp.encryption' => 'sometimes|in:ssl,tls,starttls,', 'smtp.from_name' => 'sometimes|string|nullable', 'smtp.from_address' => 'sometimes|email|nullable', + 'turnstile' => 'sometimes|array', + 'turnstile.enabled' => 'sometimes|boolean', + 'turnstile.site_key' => 'sometimes|string|nullable|max:255', + 'turnstile.secret_key' => 'sometimes|string|nullable|max:255', ]); if ($validator->fails()) { @@ -120,6 +118,14 @@ class SettingController extends Controller Cache::forget(CacheKey::build('settings', 'mail')); } + if ($request->has('turnstile')) { + try { + Turnstile::save($request->input('turnstile')); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } + } + return response()->json([ 'error' => false, 'message' => 'Cập nhật cài đặt thành công.', diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 8ed0571..72dce79 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -9,6 +9,7 @@ use App\Services\Api\Client\ClientAuthService; use App\Http\Controllers\Api\Client\Concerns\HandlesClientAuthentication; use App\Http\Controllers\Api\Client\Concerns\HandlesClientPasswordReset; +use App\Http\Controllers\Api\Client\Concerns\ManagesClientAccountSettings; use App\Http\Controllers\Api\Client\Concerns\ManagesClientProfile; /** * @OA\Tag( @@ -20,6 +21,7 @@ class ClientController extends Controller { use HandlesClientAuthentication; use HandlesClientPasswordReset; + use ManagesClientAccountSettings; use ManagesClientProfile; protected $authService; diff --git a/app/Http/Controllers/Api/Client/Concerns/ManagesClientAccountSettings.php b/app/Http/Controllers/Api/Client/Concerns/ManagesClientAccountSettings.php new file mode 100644 index 0000000..c6affea --- /dev/null +++ b/app/Http/Controllers/Api/Client/Concerns/ManagesClientAccountSettings.php @@ -0,0 +1,72 @@ +validate([ + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'in:10,20,50'], + ]); + $customer = auth('client')->user(); + + $history = ActivityLog::query() + ->where('log_name', 'auth') + ->where('causer_type', 'customer') + ->where('causer_id', $customer->id) + ->whereIn('event', ['login', 'logout']) + ->latest('created_at') + ->paginate($validated['per_page'] ?? 10); + + return response()->json([ + 'error' => false, + 'data' => collect($history->items())->map(fn (ActivityLog $log) => [ + 'id' => $log->id, + 'event' => $log->event, + 'ip_address' => $log->ip_address, + 'user_agent' => $log->user_agent, + 'created_at' => $log->created_at?->toIso8601String(), + ])->values(), + 'meta' => [ + 'current_page' => $history->currentPage(), + 'per_page' => $history->perPage(), + 'total' => $history->total(), + 'last_page' => $history->lastPage(), + ], + ]); + } + + public function updatePreferences(ClientPreferencesRequest $request) + { + $customer = auth('client')->user(); + $oldValues = $customer->only(['email_marketing', 'receive_deposit']); + + $customer->fill($request->validated()); + $dirty = $customer->getDirty(); + $customer->save(); + + if ($dirty !== []) { + $this->activityLogger->log( + event: 'preferences_update', + logName: 'Customer', + description: "Customer {$customer->email} cập nhật tuỳ chọn tài khoản", + subject: $customer, + oldValues: array_intersect_key($oldValues, $dirty), + newValues: $dirty, + ); + } + + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu cài đặt tài khoản.', + 'data' => ['client' => new ClientResource($customer->fresh())], + ]); + } +} diff --git a/app/Http/Controllers/Api/Setting/SettingController.php b/app/Http/Controllers/Api/Setting/SettingController.php index 778ecdc..9c88892 100644 --- a/app/Http/Controllers/Api/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Setting/SettingController.php @@ -4,6 +4,10 @@ namespace App\Http\Controllers\Api\Setting; use App\Http\Controllers\Controller; use App\Models\Setting; +use App\Support\ClientMenu; +use App\Support\ClientQuickLinks; +use App\Support\PublicSetting; +use App\Support\Turnstile; /** * @OA\Tag( @@ -19,7 +23,7 @@ class SettingController extends Controller * operationId="getSetting", * tags={"Settings"}, * summary="Get application settings", - * description="Returns the application settings", + * description="Returns the public application settings (title, company, logo, favi_icon, brand_color)", * @OA\Response( * response=200, * description="Successful operation", @@ -31,11 +35,10 @@ class SettingController extends Controller * ) * ) */ - public function getSetting() + public function getSetting(): \Illuminate\Http\JsonResponse { - $setting = Setting::where('key', 'setting')->first(); return response()->json([ - 'data' => $setting->value, + 'data' => PublicSetting::values(), 'message' => 'Get setting successfully', 'error' => false ]); @@ -53,4 +56,28 @@ class SettingController extends Controller ], ]); } -} \ No newline at end of file + + public function getTurnstileSetting(): \Illuminate\Http\JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => ['site_key' => Turnstile::siteKey()], + ]); + } + + public function getMenu(): \Illuminate\Http\JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => ClientMenu::items(), + ]); + } + + public function getQuickLinks(): \Illuminate\Http\JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => ClientQuickLinks::items(), + ]); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e7e3249..3576cc4 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -87,5 +87,6 @@ class Kernel extends HttpKernel 'client.access-token' => \App\Http\Middleware\ClientAccessTokenCheck::class, 'perm' => \App\Http\Middleware\CheckPermission::class, 'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class, + 'turnstile' => \App\Http\Middleware\VerifyTurnstile::class, ]; } diff --git a/app/Http/Middleware/VerifyTurnstile.php b/app/Http/Middleware/VerifyTurnstile.php new file mode 100644 index 0000000..70d5555 --- /dev/null +++ b/app/Http/Middleware/VerifyTurnstile.php @@ -0,0 +1,25 @@ +input(Turnstile::TOKEN_FIELD), $request->ip())) { + return response()->json([ + 'error' => true, + 'message' => 'Xác minh bảo mật (captcha) không hợp lệ hoặc đã hết hạn. Vui lòng thử lại.', + ], 422); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Api/Client/ClientPreferencesRequest.php b/app/Http/Requests/Api/Client/ClientPreferencesRequest.php new file mode 100644 index 0000000..181229c --- /dev/null +++ b/app/Http/Requests/Api/Client/ClientPreferencesRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'email_marketing' => ['required', 'boolean'], + 'receive_deposit' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Api/Client/ClientRegisterRequest.php b/app/Http/Requests/Api/Client/ClientRegisterRequest.php index 0d9a605..8740da9 100644 --- a/app/Http/Requests/Api/Client/ClientRegisterRequest.php +++ b/app/Http/Requests/Api/Client/ClientRegisterRequest.php @@ -27,7 +27,7 @@ class ClientRegisterRequest extends FormRequest 'max:255' ], 'phone' => [ - 'required', + 'nullable', 'digits:10', 'unique:customers,phone' ], @@ -59,7 +59,6 @@ class ClientRegisterRequest extends FormRequest { return [ 'fullname.required' => 'Tên là bắt buộc', - 'phone.required' => 'Số điện thoại là bắt buộc', 'phone.digits' => 'Số điện thoại phải có 10 chữ số', 'phone.unique' => 'Số điện thoại đã tồn tại', 'email.required' => 'Email là bắt buộc', diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index 4833159..b36ff0a 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -40,21 +40,19 @@ class ProductResource extends JsonResource 'setup_fee' => $this->setup_fee, 'min_price' => $minPrice, 'billing_type' => $this->whenLoaded('pricings', function () { - // 'one_time' (with underscore) is the convention used everywhere else in the - // app (OrderHelper::cycleToMonths, ServiceExpiry, renewService, product_pricings - // migration comment) — this used to check 'onetime' and never matched real rows. - $hasOnetime = $this->pricings->where('billing_cycle', 'one_time')->isNotEmpty(); - $hasRecurring = $this->pricings->where('billing_cycle', '!=', 'one_time')->isNotEmpty(); - return ($hasOnetime && !$hasRecurring) ? 'one_time' : 'recurring'; + $oneTimeCycles = ['onetime', 'one_time']; + $hasOnetime = $this->pricings->whereIn('billing_cycle', $oneTimeCycles)->isNotEmpty(); + $hasRecurring = $this->pricings->whereNotIn('billing_cycle', $oneTimeCycles)->isNotEmpty(); + return ($hasOnetime && !$hasRecurring) ? 'onetime' : 'recurring'; }), 'currency' => $this->whenLoaded('pricings', fn () => $this->pricings->first()?->currency ?? 'VND'), 'price_monthly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'monthly'))->price), 'price_quarterly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'quarterly'))->price), - 'price_semiannually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'semi_annually'))->price), + 'price_semiannually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->first(fn ($pricing) => in_array($pricing->billing_cycle, ['semi_annually', 'semiannually'], true)))->price), 'price_annually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'annually'))->price), 'price_biennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'biennially'))->price), 'price_triennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'triennially'))->price), - 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'one_time'))->price), + 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->first(fn ($pricing) => in_array($pricing->billing_cycle, ['onetime', 'one_time'], true)))->price), 'is_active' => $this->is_active, 'created_at' => $this->created_at, 'os_ids' => $osIds, diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php index 4242bdf..746f16d 100644 --- a/app/Http/Resources/Api/Client/ClientResource.php +++ b/app/Http/Resources/Api/Client/ClientResource.php @@ -40,6 +40,8 @@ class ClientResource extends JsonResource 'fb_link' => null, 'active_2fa' => 0, 'off_mail_marketing' => $this->off_mail_marketing, + 'email_marketing' => (bool) $this->email_marketing, + 'receive_deposit' => (bool) $this->receive_deposit, 'is_recharge_zalo' => 0, 'is_verify_telegram' => 0, ], diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index acca38d..09ba0a9 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -78,8 +78,19 @@ class RouteServiceProvider extends ServiceProvider // IP gần như luôn là IP của Next → khoá theo route + email, không theo IP (tránh chặn chung mọi khách). RateLimiter::for('client-auth', function (Request $request) { $email = strtolower(trim((string) $request->input('email'))); + $route = $request->route()?->getName(); + $byEmail = Limit::perMinute(5)->by($route . '|' . ($email ?: $request->ip())); - return Limit::perMinute(5)->by($request->route()?->getName() . '|' . ($email ?: $request->ip())); + $perIpHourly = [ + 'api.client.register' => 5, + 'api.client.resend-verify' => 10, + ]; + + if (!isset($perIpHourly[$route])) { + return $byEmail; + } + + return [$byEmail, Limit::perHour($perIpHourly[$route])->by($route . '|ip|' . $request->ip())]; }); } } diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index aed7c19..f1d0948 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -86,7 +86,7 @@ class ClientAuthService $client = $this->clientRepository->create([ 'fullname' => $data['fullname'], - 'phone' => $data['phone'], + 'phone' => $data['phone'] ?? null, 'email' => $data['email'], 'password' => bcrypt($data['password']), 'status' => 0, diff --git a/app/Support/ClientMenu.php b/app/Support/ClientMenu.php new file mode 100644 index 0000000..32b9aec --- /dev/null +++ b/app/Support/ClientMenu.php @@ -0,0 +1,75 @@ +}> + */ + public static function items(): array + { + $value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true); + + return is_array($value) ? $value : []; + } + + /** + * @return array + */ + public static function rules(): array + { + return [ + 'items' => 'present|array|max:30', + 'items.*' => 'array', + 'items.*.title' => 'required|string|max:60', + 'items.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . self::ICON_PATTERN], + 'items.*.href' => ['nullable', 'required_without:items.*.children', 'string', 'max:255', 'regex:' . self::HREF_PATTERN], + 'items.*.disabled' => 'sometimes|boolean', + 'items.*.children' => 'sometimes|array|max:20', + 'items.*.children.*.title' => 'required|string|max:60', + 'items.*.children.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . self::ICON_PATTERN], + 'items.*.children.*.href' => ['required', 'string', 'max:255', 'regex:' . self::HREF_PATTERN], + 'items.*.children.*.disabled' => 'sometimes|boolean', + ]; + } + + /** + * @param array> $items + */ + public static function save(array $items): array + { + $normalized = array_map(fn (array $item) => [ + ...self::normalizeLink($item), + 'children' => array_map(self::normalizeLink(...), array_values($item['children'] ?? [])), + ], array_values($items)); + + Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized, JSON_UNESCAPED_UNICODE)]); + + return $normalized; + } + + /** + * @param array $item + * @return array{title: string, icon: ?string, href: ?string, disabled: bool} + */ + public static function normalizeLink(array $item): array + { + return [ + 'title' => trim((string) $item['title']), + 'icon' => ($item['icon'] ?? '') !== '' ? (string) $item['icon'] : null, + 'href' => ($item['href'] ?? '') !== '' ? (string) $item['href'] : null, + 'disabled' => (bool) ($item['disabled'] ?? false), + ]; + } +} diff --git a/app/Support/ClientQuickLinks.php b/app/Support/ClientQuickLinks.php new file mode 100644 index 0000000..aab5e8a --- /dev/null +++ b/app/Support/ClientQuickLinks.php @@ -0,0 +1,49 @@ + + */ + public static function items(): array + { + $value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true); + + return is_array($value) ? $value : []; + } + + /** + * @return array + */ + public static function rules(): array + { + return [ + 'items' => 'present|array|max:6', + 'items.*' => 'array', + 'items.*.title' => 'required|string|max:40', + 'items.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . ClientMenu::ICON_PATTERN], + 'items.*.href' => ['required', 'string', 'max:255', 'regex:' . ClientMenu::HREF_PATTERN], + 'items.*.disabled' => 'sometimes|boolean', + ]; + } + + /** + * @param array> $items + */ + public static function save(array $items): array + { + $normalized = array_map(ClientMenu::normalizeLink(...), array_values($items)); + + Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized, JSON_UNESCAPED_UNICODE)]); + + return $normalized; + } +} diff --git a/app/Support/PublicSetting.php b/app/Support/PublicSetting.php new file mode 100644 index 0000000..914f1ce --- /dev/null +++ b/app/Support/PublicSetting.php @@ -0,0 +1,20 @@ + + */ + public static function values(): array + { + $value = json_decode((string) Setting::where('key', 'setting')->value('value'), true) ?: []; + + return array_map(fn (string $field) => (string) ($value[$field] ?? ''), array_combine(self::FIELDS, self::FIELDS)); + } +} diff --git a/app/Support/Turnstile.php b/app/Support/Turnstile.php new file mode 100644 index 0000000..ba9d01d --- /dev/null +++ b/app/Support/Turnstile.php @@ -0,0 +1,113 @@ +value('value'), true) ?: []; + + return [ + 'enabled' => (bool) ($value['enabled'] ?? false), + 'site_key' => (string) ($value['site_key'] ?? ''), + 'secret_key' => (string) ($value['secret_key'] ?? ''), + ]; + } + + public static function siteKey(): ?string + { + $config = self::stored(); + + return $config['enabled'] && $config['site_key'] !== '' && $config['secret_key'] !== '' + ? $config['site_key'] + : null; + } + + public static function verify(?string $token, ?string $ip): bool + { + if (self::siteKey() === null) { + return true; + } + + if ($token === null || $token === '' || strlen($token) > 2048) { + return false; + } + + try { + $response = Http::asForm()->timeout(10)->post(self::VERIFY_URL, array_filter([ + 'secret' => SignRSA::DecryptFunc(self::stored()['secret_key']), + 'response' => $token, + 'remoteip' => $ip, + ])); + } catch (Throwable $e) { + Log::warning('Turnstile siteverify failed', ['error' => $e->getMessage()]); + + return false; + } + + if ($response->json('success') !== true) { + Log::info('Turnstile rejected', ['codes' => $response->json('error-codes')]); + + return false; + } + + return true; + } + + /** + * @param array $input + */ + public static function save(array $input): void + { + $current = self::stored(); + $secret = (string) ($input['secret_key'] ?? self::SECRET_MASK); + + $config = [ + 'enabled' => (bool) ($input['enabled'] ?? $current['enabled']), + 'site_key' => trim((string) ($input['site_key'] ?? $current['site_key'])), + 'secret_key' => match (true) { + $secret === self::SECRET_MASK => $current['secret_key'], + trim($secret) === '' => '', + default => SignRSA::EncryptFunc(trim($secret)), + }, + ]; + + if ($config['enabled'] && ($config['site_key'] === '' || $config['secret_key'] === '')) { + throw new InvalidArgumentException('Cần nhập Site key và Secret key trước khi bật Turnstile.'); + } + + Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($config)]); + } + + /** + * @return array{enabled: bool, site_key: string, secret_key: string} + */ + public static function forAdmin(): array + { + $config = self::stored(); + $config['secret_key'] = $config['secret_key'] === '' ? '' : self::SECRET_MASK; + + return $config; + } +} diff --git a/database/migrations/2026_10_12_000001_seed_client_menu_setting.php b/database/migrations/2026_10_12_000001_seed_client_menu_setting.php new file mode 100644 index 0000000..ee722d2 --- /dev/null +++ b/database/migrations/2026_10_12_000001_seed_client_menu_setting.php @@ -0,0 +1,55 @@ +where('key', self::KEY)->exists()) { + return; + } + + $link = fn (string $title, string $icon, ?string $href, array $children = []) => [ + 'title' => $title, 'icon' => $icon, 'href' => $href, 'disabled' => false, 'children' => $children, + ]; + $child = fn (string $title, string $icon, string $href) => [ + 'title' => $title, 'icon' => $icon, 'href' => $href, 'disabled' => false, + ]; + + $menu = [ + $link('Dashboard', 'hugeicons:dashboard-square-01', '/'), + $link('Đăng ký dịch vụ', 'hugeicons:cloud', null, [ + $child('VPS', 'hugeicons:archive-04', '/san-pham/vps'), + $child('Tên miền', 'hugeicons:tags', '/dang-ky-ten-mien'), + $child('Hosting', 'hugeicons:hard-drive', '/san-pham/hosting'), + ]), + $link('Quản lý dịch vụ', 'hugeicons:3-d-view', null, [ + $child('VPS', 'hugeicons:archive-04', '/dich-vu/vps'), + $child('Domain', 'hugeicons:global', '/dich-vu/domain'), + $child('Hosting', 'hugeicons:hard-drive', '/dich-vu/hosting'), + ]), + $link('Quản lý thanh toán', 'hugeicons:layer', '/don-hang'), + $link('Ticket hỗ trợ', 'hugeicons:customer-service', '/tickets'), + $link('Trung tâm hướng dẫn', 'hugeicons:book-open-01', '/docs'), + $link('Nạp tiền', 'hugeicons:coins-dollar', '/nap-tien'), + $link('Lịch sử giao dịch', 'hugeicons:settings-02', '/lich-su-giao-dich'), + $link('Thông báo', 'hugeicons:notification-03', '/thong-bao'), + ]; + + DB::table('setting')->insert([ + 'key' => self::KEY, + 'value' => json_encode($menu, JSON_UNESCAPED_UNICODE), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('setting')->where('key', self::KEY)->delete(); + } +}; diff --git a/database/migrations/2026_10_12_000002_seed_client_quick_links_setting.php b/database/migrations/2026_10_12_000002_seed_client_quick_links_setting.php new file mode 100644 index 0000000..2f0825d --- /dev/null +++ b/database/migrations/2026_10_12_000002_seed_client_quick_links_setting.php @@ -0,0 +1,30 @@ +where('key', self::KEY)->exists()) { + return; + } + + DB::table('setting')->insert([ + 'key' => self::KEY, + 'value' => json_encode([ + ['title' => 'Tạo Cloud VPS', 'icon' => 'lucide:cloud', 'href' => '/san-pham/vps', 'disabled' => false], + ], JSON_UNESCAPED_UNICODE), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('setting')->where('key', self::KEY)->delete(); + } +}; diff --git a/routes/api.php b/routes/api.php index dbe8943..1824204 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,7 +19,7 @@ Route::prefix('v1') /* Api for Admin */ ->group(function () { Route::prefix('administrator')->group(function () { - Route::post('login', [AuthController::class, 'login'])->middleware('throttle:30,1'); + Route::post('login', [AuthController::class, 'login'])->middleware(['throttle:30,1', 'turnstile']); Route::middleware('api.auth:admin')->group(function () { require __DIR__.'/api/admin/auth.php'; diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 758fb29..80fbb6c 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\Admin\ActivityLog\ActivityLogController; use App\Http\Controllers\Api\Admin\EmailLog\EmailLogController; use App\Http\Controllers\Api\Admin\EmailTemplate\EmailTemplateController; use App\Http\Controllers\Api\Admin\MailCampaign\MailCampaignController; +use App\Http\Controllers\Api\Admin\Setting\ClientMenuController; use App\Http\Controllers\Api\Admin\Setting\SettingController as AdminSettingController; use App\Http\Controllers\Api\Admin\Staff\StaffOnlineController; use App\Http\Controllers\Api\Admin\Ticket\TicketController as AdminTicketController; @@ -21,6 +22,16 @@ Route::prefix('settings') Route::post('test-telegram', 'testTelegram'); }); +Route::prefix('client-menu') + ->controller(ClientMenuController::class) + ->middleware('perm:setting.manager') + ->group(function () { + Route::get('/', 'show'); + Route::put('/', 'update'); + Route::get('quick-links', 'showQuickLinks'); + Route::put('quick-links', 'updateQuickLinks'); + }); + // VAT settings per service type (module: settings) Route::prefix('vat-settings') ->controller(AdminSettingController::class) diff --git a/routes/client.php b/routes/client.php index f391e5f..310d61e 100644 --- a/routes/client.php +++ b/routes/client.php @@ -31,13 +31,13 @@ Route::prefix('v1') ->as('client.') ->group(function () { Route::controller(ClientController::class)->group(function () { - Route::post('login', 'login')->middleware('throttle:client-auth')->name('login'); - Route::post('register', 'register')->middleware('throttle:client-auth')->name('register'); + Route::post('login', 'login')->middleware(['throttle:client-auth', 'turnstile'])->name('login'); + Route::post('register', 'register')->middleware(['throttle:client-auth', 'turnstile'])->name('register'); Route::post('verify-email', 'verifyEmail')->middleware('throttle:client-auth')->name('verify-email'); Route::post('resend-verify', 'resendVerifyCode')->middleware('throttle:client-auth')->name('resend-verify'); Route::post('refresh', 'refresh')->name('refresh'); - Route::post('forgot-password', 'forgotPassword')->middleware('throttle:client-auth')->name('forgot-password'); - Route::post('reset-password', 'resetPassword')->middleware('throttle:client-auth')->name('reset-password'); + Route::post('forgot-password', 'forgotPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('forgot-password'); + Route::post('reset-password', 'resetPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('reset-password'); // Ảnh CCCD nằm trên disk private, chỉ truy cập qua URL ký (ClientResource cấp, hết hạn 60 phút) Route::get('cccd/{customer}/{side}', 'cccdImage') ->whereNumber('customer') @@ -50,6 +50,8 @@ Route::prefix('v1') Route::get('me', 'me')->name('me'); Route::post('logout', 'logout')->name('logout'); Route::put('update', 'update')->name('update'); + Route::get('login-history', 'loginHistory')->name('login-history'); + Route::put('preferences', 'updatePreferences')->name('preferences.update'); Route::post('update-cccd', 'updateCccd')->name('update-cccd'); Route::put('change-password', 'changePassword')->name('change-password'); }); @@ -195,6 +197,9 @@ Route::prefix('v1') Route::controller(SettingController::class)->group(function () { Route::get('/', 'getSetting')->name('get'); Route::get('email', 'getEmailSetting')->name('email'); + Route::get('turnstile', 'getTurnstileSetting')->name('turnstile'); + Route::get('menu', 'getMenu')->name('menu'); + Route::get('quick-links', 'getQuickLinks')->name('quick-links'); }); }); diff --git a/tests/Feature/Api/Admin/ClientMenuTest.php b/tests/Feature/Api/Admin/ClientMenuTest.php new file mode 100644 index 0000000..644f7d8 --- /dev/null +++ b/tests/Feature/Api/Admin/ClientMenuTest.php @@ -0,0 +1,89 @@ + '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']); + } +} diff --git a/tests/Feature/Api/Admin/PermissionGatingTest.php b/tests/Feature/Api/Admin/PermissionGatingTest.php index c9b9047..9172152 100644 --- a/tests/Feature/Api/Admin/PermissionGatingTest.php +++ b/tests/Feature/Api/Admin/PermissionGatingTest.php @@ -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'], diff --git a/tests/Feature/Api/Client/ClientAccountSettingsTest.php b/tests/Feature/Api/Client/ClientAccountSettingsTest.php new file mode 100644 index 0000000..239c34a --- /dev/null +++ b/tests/Feature/Api/Client/ClientAccountSettingsTest.php @@ -0,0 +1,89 @@ +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']); + } +} diff --git a/tests/Feature/Api/Client/PublicSettingTest.php b/tests/Feature/Api/Client/PublicSettingTest.php new file mode 100644 index 0000000..f6ca6db --- /dev/null +++ b/tests/Feature/Api/Client/PublicSettingTest.php @@ -0,0 +1,42 @@ + '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); + } + } +} diff --git a/tests/Feature/Api/Client/SecurityHardeningTest.php b/tests/Feature/Api/Client/SecurityHardeningTest.php index 126930c..f6b43c2 100644 --- a/tests/Feature/Api/Client/SecurityHardeningTest.php +++ b/tests/Feature/Api/Client/SecurityHardeningTest.php @@ -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 diff --git a/tests/Feature/Support/RegisterThrottleTest.php b/tests/Feature/Support/RegisterThrottleTest.php new file mode 100644 index 0000000..aa054d7 --- /dev/null +++ b/tests/Feature/Support/RegisterThrottleTest.php @@ -0,0 +1,36 @@ +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')); + } +} diff --git a/tests/Feature/Support/TurnstileTest.php b/tests/Feature/Support/TurnstileTest.php new file mode 100644 index 0000000..09de9e4 --- /dev/null +++ b/tests/Feature/Support/TurnstileTest.php @@ -0,0 +1,72 @@ + 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(); + } +} diff --git a/tests/Unit/AdminProductResourceTest.php b/tests/Unit/AdminProductResourceTest.php new file mode 100644 index 0000000..8a12e77 --- /dev/null +++ b/tests/Unit/AdminProductResourceTest.php @@ -0,0 +1,64 @@ + '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']); + } +}