Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 121
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SeedE2eUsersCommand
0.00% covered (danger)
0.00%
0 / 121
0.00% covered (danger)
0.00%
0 / 9
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 configure
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 seedInvestor
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
6
 resetPendingLoans
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 seedStaffUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 generateAccountNumber
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 requireEnv
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 loadE2eEnvFile
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3declare(strict_types=1);
4
5namespace App\Console;
6
7use App\Domain\Auth\Service\PasswordService;
8use PDO;
9use PDOException;
10use RuntimeException;
11use Symfony\Component\Console\Command\Command;
12use Symfony\Component\Console\Input\InputInterface;
13use Symfony\Component\Console\Output\OutputInterface;
14use Throwable;
15
16/**
17 * Idempotently provisions the three test accounts the Playwright suite logs in
18 * as. Safe to re-run — existing rows are reset (password rehashed, lockouts
19 * cleared, role normalized) rather than duplicated.
20 *
21 * Usage:
22 *   php bin/console.php e2e:seed-users
23 *
24 * Refuses to run when APP_ENV=production. Reads required credentials from the
25 * E2E_* environment variables (see .env.e2e.example).
26 */
27final class SeedE2eUsersCommand extends Command
28{
29    public function __construct(
30        private readonly PDO $pdo,
31        private readonly PasswordService $passwordService,
32    ) {
33        parent::__construct();
34    }
35
36    protected function configure(): void
37    {
38        parent::configure();
39
40        $this->setName('e2e:seed-users');
41        $this->setDescription('Create or reset the three Playwright test accounts (idempotent).');
42    }
43
44    protected function execute(InputInterface $input, OutputInterface $output): int
45    {
46        $env = $_ENV['APP_ENV'] ?? 'unknown';
47
48        if ($env === 'production' || $env === 'prod') {
49            $output->writeln('<error>Refusing to seed e2e users in production.</error>');
50
51            return Command::FAILURE;
52        }
53
54        $this->loadE2eEnvFile();
55
56        $investorEmail = $this->requireEnv('E2E_INVESTOR_EMAIL');
57        $investorPassword = $this->requireEnv('E2E_INVESTOR_PASSWORD');
58        $adminEmail = $this->requireEnv('E2E_ADMIN_EMAIL');
59        $adminPassword = $this->requireEnv('E2E_ADMIN_PASSWORD');
60        $superAdminEmail = $this->requireEnv('E2E_SUPERADMIN_EMAIL');
61        $superAdminPassword = $this->requireEnv('E2E_SUPERADMIN_PASSWORD');
62
63        // PandaDoc constraint: sandbox only delivers to @apiaryfund.com. Refuse
64        // to seed anything that would silently break the document-signing flow.
65        foreach ([$investorEmail, $adminEmail, $superAdminEmail] as $email) {
66            if (!str_ends_with($email, '@apiaryfund.com')) {
67                $output->writeln(sprintf(
68                    '<error>E2E email %s is not on @apiaryfund.com — PandaDoc sandbox will not deliver to it.</error>',
69                    $email,
70                ));
71
72                return Command::FAILURE;
73            }
74        }
75
76        $this->pdo->beginTransaction();
77
78        try {
79            $this->seedInvestor($investorEmail, $investorPassword, $output);
80            $this->seedStaffUser($adminEmail, $adminPassword, 'admin', 'e2e-admin', $output);
81            $this->seedStaffUser($superAdminEmail, $superAdminPassword, 'super_admin', 'e2e-superadmin', $output);
82
83            $this->pdo->commit();
84        } catch (Throwable $e) {
85            $this->pdo->rollBack();
86            $output->writeln('<error>Seed failed: ' . $e->getMessage() . '</error>');
87
88            throw $e;
89        }
90
91        $output->writeln('<info>E2E users ready.</info>');
92
93        return Command::SUCCESS;
94    }
95
96    private function seedInvestor(string $email, string $password, OutputInterface $output): void
97    {
98        $hash = $this->passwordService->hashPassword($password);
99
100        // Investor row — upserted by email
101        $stmt = $this->pdo->prepare(
102            "INSERT INTO investors (
103                first_name, last_name, email, date_of_birth, phone,
104                address_line1, city, state, zip_code, country,
105                status, kyc_status, created_at, updated_at
106            ) VALUES (
107                'E2E', 'Investor', :email, '1990-01-01', '5555550100',
108                '123 Test Lane', 'Cheyenne', 'WY', '82001', 'United States',
109                'active', 'verified', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
110            )
111            ON CONFLICT (email) DO UPDATE
112                SET first_name = EXCLUDED.first_name,
113                    last_name = EXCLUDED.last_name,
114                    status = 'active',
115                    kyc_status = 'verified',
116                    updated_at = CURRENT_TIMESTAMP
117            RETURNING investor_id"
118        );
119        $stmt->execute(['email' => $email]);
120        $investorId = (int)$stmt->fetchColumn();
121
122        // User row — upserted by email
123        $userStmt = $this->pdo->prepare(
124            "INSERT INTO users (
125                investor_id, username, email, password_hash, role, is_active,
126                failed_login_attempts, locked_until, created_at, updated_at
127            ) VALUES (
128                :investorId, :username, :email, :hash, 'investor', true,
129                0, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
130            )
131            ON CONFLICT (email) DO UPDATE
132                SET investor_id = EXCLUDED.investor_id,
133                    password_hash = EXCLUDED.password_hash,
134                    role = 'investor',
135                    is_active = true,
136                    failed_login_attempts = 0,
137                    locked_until = NULL,
138                    updated_at = CURRENT_TIMESTAMP"
139        );
140        $userStmt->execute([
141            'investorId' => $investorId,
142            'username' => 'e2e-investor',
143            'email' => $email,
144            'hash' => $hash,
145        ]);
146
147        // Reset loan state so the loan-flow specs start each run with full
148        // borrowing capacity. Pending loans pile up across runs and shrink the
149        // eligible amount until the suite can't request a loan at all.
150        $this->resetPendingLoans($investorId, $output);
151
152        // Account row — only create if the investor has none yet (don't clobber
153        // balances if the suite has run before and made transactions).
154        $accountStmt = $this->pdo->prepare(
155            'SELECT account_id FROM accounts WHERE investor_id = :investorId LIMIT 1'
156        );
157        $accountStmt->execute(['investorId' => $investorId]);
158        $existingAccountId = $accountStmt->fetchColumn();
159
160        if ($existingAccountId === false) {
161            $accountNumber = $this->generateAccountNumber();
162            // Active accounts must hold >= $25,000 (accounts_active_minimum_balance_check).
163            // Seed $30k so the e2e investor has headroom to request loans, make
164            // payments, and otherwise mutate balance during the suite.
165            $insertAccount = $this->pdo->prepare(
166                "INSERT INTO accounts (
167                    investor_id, account_number, balance, available_balance,
168                    interest_rate, loan_to_value_ratio, status, created_at, updated_at
169                ) VALUES (
170                    :investorId, :accountNumber, 30000, 30000,
171                    NULL, 0.80, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
172                )"
173            );
174            $insertAccount->execute([
175                'investorId' => $investorId,
176                'accountNumber' => $accountNumber,
177            ]);
178            $output->writeln(sprintf('  - investor %s ready (new account %s)', $email, $accountNumber));
179        } else {
180            $output->writeln(sprintf('  - investor %s ready (existing account)', $email));
181        }
182    }
183
184    /**
185     * Delete the e2e investor's pre-disbursement loans so each suite run starts
186     * with full borrowing capacity.
187     *
188     * Only loans that never moved money are removed (requested / under_review /
189     * approved / pending_acceptance / pending). These have no schedule or
190     * payment rows, so a plain delete is clean and can't orphan transactions.
191     * Disbursed/active/paid_off loans are left untouched — a completed lifecycle
192     * spec pays its loan off (terminal, frees capacity) on its own, and we must
193     * never silently erase real money movement.
194     * @param int $investorId
195     * @param OutputInterface $output
196     */
197    private function resetPendingLoans(int $investorId, OutputInterface $output): void
198    {
199        $preDisbursement = ['requested', 'under_review', 'approved', 'pending_acceptance', 'pending'];
200        $placeholders = implode(', ', array_fill(0, count($preDisbursement), '?'));
201
202        $stmt = $this->pdo->prepare(
203            "DELETE FROM loans WHERE investor_id = ? AND status IN ($placeholders)"
204        );
205        $stmt->execute([$investorId, ...$preDisbursement]);
206
207        $deleted = $stmt->rowCount();
208        if ($deleted > 0) {
209            $output->writeln(sprintf('  - cleared %d pending loan(s) for investor %d', $deleted, $investorId));
210        }
211    }
212
213    private function seedStaffUser(
214        string $email,
215        string $password,
216        string $role,
217        string $username,
218        OutputInterface $output,
219    ): void {
220        $hash = $this->passwordService->hashPassword($password);
221
222        $stmt = $this->pdo->prepare(
223            "INSERT INTO users (
224                investor_id, username, email, password_hash, role, is_active,
225                failed_login_attempts, locked_until, created_at, updated_at
226            ) VALUES (
227                NULL, :username, :email, :hash, :role, true,
228                0, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
229            )
230            ON CONFLICT (email) DO UPDATE
231                SET password_hash = EXCLUDED.password_hash,
232                    role = EXCLUDED.role,
233                    is_active = true,
234                    failed_login_attempts = 0,
235                    locked_until = NULL,
236                    updated_at = CURRENT_TIMESTAMP"
237        );
238        $stmt->execute([
239            'username' => $username,
240            'email' => $email,
241            'hash' => $hash,
242            'role' => $role,
243        ]);
244
245        $output->writeln(sprintf('  - %s %s ready', $role, $email));
246    }
247
248    private function generateAccountNumber(): string
249    {
250        for ($i = 0; $i < 10; $i++) {
251            $candidate = sprintf('E2E-%05d', random_int(10000, 99999));
252            $stmt = $this->pdo->prepare(
253                'SELECT 1 FROM accounts WHERE account_number = :candidate LIMIT 1'
254            );
255            $stmt->execute(['candidate' => $candidate]);
256            if ($stmt->fetchColumn() === false) {
257                return $candidate;
258            }
259        }
260
261        throw new RuntimeException('Could not generate a unique e2e account number');
262    }
263
264    private function requireEnv(string $key): string
265    {
266        $value = $_ENV[$key] ?? getenv($key);
267
268        if (!is_string($value) || $value === '') {
269            throw new RuntimeException(sprintf(
270                'Missing required env var %s — copy .env.e2e.example to .env.e2e and fill it in.',
271                $key,
272            ));
273        }
274
275        return $value;
276    }
277
278    /**
279     * Load .env.e2e from the repo root into $_ENV. Tolerates a missing file
280     * (env vars may be exported by the caller instead). Existing env values
281     * are NOT overwritten — explicit exports always win.
282     */
283    private function loadE2eEnvFile(): void
284    {
285        // server/src/Console/SeedE2eUsersCommand.php → repo root is three levels up.
286        $path = __DIR__ . '/../../../.env.e2e';
287
288        if (!is_file($path) || !is_readable($path)) {
289            return;
290        }
291
292        $contents = file_get_contents($path);
293        if ($contents === false) {
294            return;
295        }
296
297        foreach (preg_split('/\R/', $contents) ?: [] as $line) {
298            $line = trim($line);
299            if ($line === '' || str_starts_with($line, '#')) {
300                continue;
301            }
302            if (!preg_match('/^([A-Z0-9_]+)\s*=\s*(.*)$/', $line, $m)) {
303                continue;
304            }
305            $key = $m[1];
306            $value = trim($m[2], "\"' \t");
307
308            if (!isset($_ENV[$key]) && getenv($key) === false) {
309                $_ENV[$key] = $value;
310            }
311        }
312    }
313}