Prepare the project for production deployment and final client
acceptance testing. Adds preflight fail-fast command, hardened FX rate
importer, currency seeder, deploy scripts, E2E smoke test, and the
43-item UAT checklist document with sign-off form.
Key changes:
- Phase 3.9.1 — hotfix (AddressBookEntry):
- Add $attributes defaults (use_count=0, is_default=false) so
instance values are populated right after create() without
refresh(). Resolves the red test
`it_increments_use_count_and_sets_last_used_at`.
- Phase 3.10 — deploy infrastructure (Laravel):
- Add `ifnex:preflight` command: fail-fast audit of production
environment (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY,
IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS,
currencies seeded, PHP extensions, storage writable). Exits 1
if any check fails; supports --skip-db for pre-migrate runs.
- Add `ifnex:import-fx-rates` command: replacement for
`ifnex:update-rates`. Fetches USD/AED from Exir.io + EUR/CNY
from FreeCurrencyAPI. Supports --auto-approve (cron use) and
--threshold (max change % for auto-approve). Logs critical on
HTTP failure. URL configurable via EXIR_API_URL env.
- Add `CurrencySeeder`: creates 5 base currencies (USD/AED/EUR/
CNY/IRR) with rate=0 (IRR=1). Idempotent — does not overwrite
real rates already set by admin or cron.
- Update `DatabaseSeeder`: skip super_admin with password
'password' in production (still created in local/testing).
- Fix `bootstrap/app.php`: remove dead `use ApiKeyAuth` import
(actual class is ApiKeyMiddleware). Latent fatal if api_key
alias was used.
- Add `config/ifnex.php` exir block + preflight config.
- Harden `.env.example`: MySQL default, EXIR_API_URL,
SANCTUM_STATEFUL_DOMAINS, APP_TIMEZONE, LOG_DAILY_DAYS, fix
duplicate CURRENCY_API_KEY.
- Phase 3.10 — deploy scripts (Linux):
- Add `scripts/deploy.sh`: idempotent 13-step deploy (composer
install → key:generate → preflight → migrate → seed → FX import
→ cache → chown). Fail-fast with set -e.
- Add `scripts/cron-exir-rates.sh`: daily cron wrapper for
`ifnex:import-fx-rates --source=all --auto-approve`.
- Phase 3.10.1 — preflight hotfix:
- Make expected DB connection configurable
(ifnex.preflight.expected_db_connection) instead of hardcoded
'mysql'. Prevents cascade test failure when tests would have to
mutate database.default to mysql in a sqlite test environment
(Unknown database ':memory:' → broken connection pool → 22
previously-green tests fail with "There is already an active
transaction").
- Rewrite IfnexPreflightTest to use config() rather than mutating
database.default.
- Phase 3.11 — UAT:
- Add `ifnex:smoke-test` command: E2E post-deploy automated test
(25+ checks: DB connection, core tables, currencies with
non-zero rates, countries count, roles, admin user, system
settings, storage writable, config/route cache, 4 API
endpoints: /up, /countries, /track/{invalid}, /customer/profile
without token).
- Add 9 feature tests for smoke-test (PASS + 6 FAIL + custom URL
+ 422/500 handling + auth bypass detection).
- Add `UAT-3.11.md`: 43-item Persian checklist for client
acceptance testing across 15 feature areas. Includes sign-off
form for both parties (client + VernaSoft).
- Update `CLIENT_DELIVERY.md` to record phases 3.10 and 3.11.
Tests:
- Total: 51 → 82 tests, 150 → 220+ assertions, all green.
- New: IfnexPreflightTest (9), IfnexImportFxRatesTest (9),
CurrencySeederTest (4), IfnexSmokeTestTest (9) = 31 new tests.
Documentation:
- DEPLOYMENT_HANDOFF.md §2.4: add CurrencySeeder + import-fx-rates.
- DEPLOYMENT_HANDOFF.md §2.8: update cron to use cron-exir-rates.sh.
- UAT-3.11.md: new 43-item client acceptance checklist + sign-off.
- CLIENT_DELIVERY.md: record phases 3.10 and 3.11 completion.
274 lines
10 KiB
PHP
274 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* IfnexPreflight — فاز ۳.۱۰
|
||
*
|
||
* قبل از migrate روی سرور تولید اجرا میشود و اگر محیط production-hardened
|
||
* نباشد، با کد غیرصفر خارج میشود تا دیپلوی fail-fast بشود.
|
||
*
|
||
* موارد بررسیشده:
|
||
* - APP_ENV, APP_DEBUG, APP_KEY
|
||
* - چرخش IFNEX_API_KEY و IFNEX_BRIDGE_API_KEY (نه مقادیر پیشفرض)
|
||
* - غیرفعال بودن ZARINPAL_SANDBOX و واقعی بودن ZARINPAL_MERCHANT_ID
|
||
* - DB_CONNECTION = mysql
|
||
* - CORS_ALLOWED_ORIGINS شامل localhost نباشد
|
||
* - وجود ۵ ارز پایه (USD/AED/EUR/CNY/IRR)
|
||
* - قابلنوشتن بودن storage و bootstrap/cache
|
||
* - اکستنشنهای ضروری PHP
|
||
*
|
||
* خروجی:
|
||
* - SUCCESS (0) اگر همه چیز سبز بود
|
||
* - FAILURE (1) اگر حتی یک مورد قرمز بود
|
||
*
|
||
* نمونهٔ استفاده:
|
||
* php artisan ifnex:preflight
|
||
* php artisan ifnex:preflight --skip-db # نادیده گرفتن بررسی دیتابیس (پیش از migrate)
|
||
*/
|
||
class IfnexPreflight extends Command
|
||
{
|
||
protected $signature = 'ifnex:preflight
|
||
{--skip-db : از بررسی دیتابیس صرفنظر کن (پیش از migrate)}';
|
||
|
||
protected $description = 'بررسی پیشاز-دیپلوی: محیط production باید hardened باشد؛ در غیر این صورت با کد ۱ خارج میشود';
|
||
|
||
/** @var array<int,array{check:string,result:string,passed:bool}> */
|
||
private array $results = [];
|
||
|
||
public function handle(): int
|
||
{
|
||
$this->info('🔍 در حال اجرای بررسی پیشاز-دیپلوی IFNEX...');
|
||
$this->newLine();
|
||
|
||
$this->checkEnv();
|
||
$this->checkSecrets();
|
||
$this->checkPayment();
|
||
$this->checkDatabase();
|
||
$this->checkCors();
|
||
$this->checkStoragePermissions();
|
||
$this->checkPhpExtensions();
|
||
|
||
if (! $this->option('skip-db')) {
|
||
$this->checkCurrenciesSeeded();
|
||
} else {
|
||
$this->addResult('currencies seeded', 'skipped (--skip-db)', true);
|
||
}
|
||
|
||
$this->renderReport();
|
||
|
||
$failed = collect($this->results)->filter(fn ($r) => ! $r['passed'])->count();
|
||
|
||
if ($failed > 0) {
|
||
$this->newLine();
|
||
$this->error("❌ {$failed} مورد ناموفق — دیپلوی را ادامه ندهید تا رفع شوند.");
|
||
|
||
return Command::FAILURE;
|
||
}
|
||
|
||
$this->newLine();
|
||
$this->info('✅ همهٔ بررسیها سبز هستند. آمادهٔ migrate و دیپلوی.');
|
||
|
||
return Command::SUCCESS;
|
||
}
|
||
|
||
// ─── محیط ───────────────────────────────────────────────
|
||
|
||
private function checkEnv(): void
|
||
{
|
||
$env = config('app.env');
|
||
$debug = config('app.debug');
|
||
$key = config('app.key');
|
||
|
||
$this->addResult(
|
||
'APP_ENV = production',
|
||
"APP_ENV = {$env}",
|
||
$env === 'production'
|
||
);
|
||
|
||
$this->addResult(
|
||
'APP_DEBUG = false',
|
||
'APP_DEBUG = ' . ($debug ? 'true' : 'false'),
|
||
$debug === false
|
||
);
|
||
|
||
$this->addResult(
|
||
'APP_KEY تنظیم شده',
|
||
$key ? 'present (' . strlen($key) . ' chars)' : 'MISSING',
|
||
!empty($key)
|
||
);
|
||
}
|
||
|
||
// ─── کلیدهای امنیتی ─────────────────────────────────────
|
||
|
||
private function checkSecrets(): void
|
||
{
|
||
$forbidden = ['change-me', 'change-this-secret-key', '', null];
|
||
|
||
$apiKey = config('ifnex.api_key');
|
||
$this->addResult(
|
||
'IFNEX_API_KEY چرخش داده شده',
|
||
in_array($apiKey, $forbidden, true) ? 'STILL DEFAULT / EMPTY' : 'rotated (' . strlen($apiKey) . ' chars)',
|
||
!in_array($apiKey, $forbidden, true)
|
||
);
|
||
|
||
$bridgeKey = config('ifnex.bridge_api_key');
|
||
$this->addResult(
|
||
'IFNEX_BRIDGE_API_KEY چرخش داده شده',
|
||
in_array($bridgeKey, $forbidden, true) ? 'STILL DEFAULT / EMPTY' : 'rotated (' . strlen($bridgeKey) . ' chars)',
|
||
!in_array($bridgeKey, $forbidden, true)
|
||
);
|
||
}
|
||
|
||
// ─── درگاه پرداخت ───────────────────────────────────────
|
||
|
||
private function checkPayment(): void
|
||
{
|
||
$sandbox = (bool) config('ifnex.zarinpal.sandbox');
|
||
$merchant = (string) config('ifnex.zarinpal.merchant_id');
|
||
$fakeValues = ['fake-merchant-id-for-testing', '', 'YOUR_REAL_MERCHANT_ID', 'xxxx-xxxx-xxxx-xxxx'];
|
||
|
||
$this->addResult(
|
||
'ZARINPAL_SANDBOX = false',
|
||
'ZARINPAL_SANDBOX = ' . ($sandbox ? 'true' : 'false'),
|
||
$sandbox === false
|
||
);
|
||
|
||
$merchantOk = ! in_array($merchant, $fakeValues, true) && strlen($merchant) >= 8;
|
||
$this->addResult(
|
||
'ZARINPAL_MERCHANT_ID واقعی',
|
||
in_array($merchant, $fakeValues, true) ? "STILL PLACEHOLDER ({$merchant})" : 'looks real (' . strlen($merchant) . ' chars)',
|
||
$merchantOk
|
||
);
|
||
}
|
||
|
||
// ─── دیتابیس ────────────────────────────────────────────
|
||
|
||
private function checkDatabase(): void
|
||
{
|
||
$conn = config('database.default');
|
||
// مقدار مورد انتظار از config خوانده میشود (پیشفرض mysql).
|
||
// در محیط تست میتوان آن را روی sqlite گذاشت تا بدون اتصال واقعی به MySQL تست شود.
|
||
$expected = config('ifnex.preflight.expected_db_connection', 'mysql');
|
||
$this->addResult(
|
||
"DB_CONNECTION = {$expected}",
|
||
"DB_CONNECTION = {$conn}",
|
||
$conn === $expected
|
||
);
|
||
}
|
||
|
||
// ─── CORS ───────────────────────────────────────────────
|
||
|
||
private function checkCors(): void
|
||
{
|
||
$allowed = config('ifnex.cors_allowed_origins', []);
|
||
$allowed = is_array($allowed) ? $allowed : [$allowed];
|
||
|
||
$forbiddenFragments = ['localhost', '127.0.0.1', '0.0.0.0'];
|
||
$hasLocal = false;
|
||
foreach ($allowed as $origin) {
|
||
foreach ($forbiddenFragments as $frag) {
|
||
if (str_contains((string) $origin, $frag)) {
|
||
$hasLocal = true;
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
$display = implode(', ', $allowed);
|
||
$this->addResult(
|
||
'CORS_ALLOWED_ORIGINS بدون localhost',
|
||
"CORS_ALLOWED_ORIGINS = {$display}",
|
||
! $hasLocal && count($allowed) >= 1
|
||
);
|
||
}
|
||
|
||
// ─── دسترسی فایل ────────────────────────────────────────
|
||
|
||
private function checkStoragePermissions(): void
|
||
{
|
||
$paths = [
|
||
base_path('storage'),
|
||
base_path('storage/logs'),
|
||
base_path('storage/app'),
|
||
base_path('storage/framework'),
|
||
base_path('bootstrap/cache'),
|
||
];
|
||
|
||
foreach ($paths as $path) {
|
||
$rel = ltrim(str_replace(base_path(), '', $path), '/');
|
||
$exists = file_exists($path);
|
||
$writable = $exists && is_writable($path);
|
||
$this->addResult(
|
||
"writable: {$rel}",
|
||
$exists ? ($writable ? 'writable' : 'NOT writable') : 'MISSING',
|
||
$writable
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── اکستنشنهای PHP ─────────────────────────────────────
|
||
|
||
private function checkPhpExtensions(): void
|
||
{
|
||
// فقط اکستنشنهای مستقیماً لازم (مطابق DEPLOYMENT_HANDOFF.md §۱)
|
||
$required = ['pdo_mysql', 'mbstring', 'xml', 'gd', 'zip', 'fileinfo', 'curl', 'ctype', 'tokenizer', 'json'];
|
||
foreach ($required as $ext) {
|
||
$loaded = extension_loaded($ext);
|
||
$this->addResult(
|
||
"ext: {$ext}",
|
||
$loaded ? 'loaded' : 'MISSING',
|
||
$loaded
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── ارزها ──────────────────────────────────────────────
|
||
|
||
private function checkCurrenciesSeeded(): void
|
||
{
|
||
$required = ['USD', 'AED', 'EUR', 'CNY', 'IRR'];
|
||
try {
|
||
$existing = DB::table('currencies')->whereIn('code', $required)->pluck('code')->all();
|
||
} catch (\Throwable $e) {
|
||
$this->addResult(
|
||
'currencies table seeded',
|
||
'CANNOT QUERY — ' . $e->getMessage(),
|
||
false
|
||
);
|
||
return;
|
||
}
|
||
|
||
$missing = array_diff($required, $existing);
|
||
$this->addResult(
|
||
'currencies table seeded (USD/AED/EUR/CNY/IRR)',
|
||
empty($missing)
|
||
? 'all 5 present'
|
||
: 'MISSING: ' . implode(',', $missing),
|
||
empty($missing)
|
||
);
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────
|
||
|
||
private function addResult(string $check, string $result, bool $passed): void
|
||
{
|
||
$this->results[] = compact('check', 'result', 'passed');
|
||
}
|
||
|
||
private function renderReport(): void
|
||
{
|
||
$this->table(
|
||
['Check', 'Value', 'Status'],
|
||
collect($this->results)->map(fn ($r) => [
|
||
$r['check'],
|
||
$r['result'],
|
||
$r['passed'] ? '<fg=green>✓ PASS</>' : '<fg=red>✗ FAIL</>',
|
||
])->all()
|
||
);
|
||
}
|
||
}
|