Remove encryption from browser tests and demo user (#129)
## Summary - Removed all `setupEncryptionKey()` / `visitWithEncryptionKey()` calls from browser tests — encryption key setup in localStorage is no longer needed since new users don't have encryption - Removed `encryption_salt` from `UserFactory::onboarded()` state and `OnboardingFlowTest` user creation - Removed `name_iv` from `Account::factory()` calls in `BankAccountsTest` - Deleted `DemoEncryptionService` and its unit test — demo command now stores plaintext account names and transaction descriptions - Removed `demoEncryptionKey` Inertia shared prop and `encryption_key` from demo config - Removed encryption helper methods from `TestCase.php` and global `setupEncryptionKey()` from `Pest.php` ## Test plan - [x] Run `php artisan test --exclude-testsuite=Browser` — all non-browser tests pass - [x] Run `php artisan test --testsuite=Browser` — browser tests pass without encryption key setup - [x] Run `php artisan demo:reset` — demo account created with plaintext data - [x] Verify existing encryption migration tests still pass (`EncryptionTest`, `DecryptTransactionsTest`, `PlaintextTransactionsTest`)
This commit is contained in:
parent
f8e8fc4db1
commit
a53e2be57b
|
|
@ -10,13 +10,11 @@ use App\Models\Account;
|
|||
use App\Models\Bank;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\EncryptedMessage;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetPeriodService;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use App\Services\Demo\DemoAutomationRulesProvider;
|
||||
use App\Services\Demo\DemoEncryptionService;
|
||||
use App\Services\Demo\DemoLabelsProvider;
|
||||
use App\Services\Demo\DemoTransactionsProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
|
@ -30,13 +28,10 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
private const MIN_BALANCE_GROWTH_PERCENTAGE = 0.05;
|
||||
|
||||
private string $encryptionKey;
|
||||
|
||||
public function __construct(
|
||||
private DemoTransactionsProvider $transactionsProvider,
|
||||
private DemoLabelsProvider $labelsProvider,
|
||||
private DemoAutomationRulesProvider $rulesProvider,
|
||||
private DemoEncryptionService $encryptionService,
|
||||
private BudgetPeriodService $budgetPeriodService,
|
||||
private BudgetTransactionService $budgetTransactionService,
|
||||
) {
|
||||
|
|
@ -47,7 +42,6 @@ class ResetDemoAccountCommand extends Command
|
|||
{
|
||||
$demoEmail = config('app.demo.email');
|
||||
$demoPassword = config('app.demo.password');
|
||||
$demoEncryptionKey = config('app.demo.encryption_key');
|
||||
|
||||
if (! $demoEmail || ! $demoPassword) {
|
||||
$this->error('Demo configuration not set. Please set DEMO_EMAIL and DEMO_PASSWORD in .env');
|
||||
|
|
@ -57,15 +51,10 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
$this->info("Resetting demo account: {$demoEmail}");
|
||||
|
||||
$salt = $this->encryptionService->generateSalt($demoEncryptionKey);
|
||||
$this->encryptionKey = $this->encryptionService->deriveKey($demoEncryptionKey, $salt);
|
||||
|
||||
$user = $this->findOrCreateDemoUser($demoEmail, $demoPassword, $salt);
|
||||
$user = $this->findOrCreateDemoUser($demoEmail, $demoPassword);
|
||||
|
||||
$this->deleteExistingData($user);
|
||||
|
||||
$this->createEncryptedMessage($user);
|
||||
|
||||
$this->createCategories($user);
|
||||
|
||||
$labels = $this->createLabels($user);
|
||||
|
|
@ -85,12 +74,11 @@ class ResetDemoAccountCommand extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function findOrCreateDemoUser(string $email, string $password, string $salt): User
|
||||
private function findOrCreateDemoUser(string $email, string $password): User
|
||||
{
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if ($user) {
|
||||
$user->encryption_salt = $salt;
|
||||
$user->email_verified_at ??= now();
|
||||
$user->save();
|
||||
|
||||
|
|
@ -102,7 +90,6 @@ class ResetDemoAccountCommand extends Command
|
|||
'name' => 'Demo User',
|
||||
'password' => $password,
|
||||
'onboarded_at' => now(),
|
||||
'encryption_salt' => $salt,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
$user->email_verified_at = now();
|
||||
|
|
@ -124,23 +111,6 @@ class ResetDemoAccountCommand extends Command
|
|||
$this->info(' Deleted existing data');
|
||||
}
|
||||
|
||||
private function createEncryptedMessage(User $user): void
|
||||
{
|
||||
$testMessage = $this->encryptionService->encrypt(
|
||||
'Hello, world',
|
||||
$this->encryptionKey,
|
||||
'demo_test_message'
|
||||
);
|
||||
|
||||
EncryptedMessage::create([
|
||||
'user_id' => $user->id,
|
||||
'encrypted_content' => $testMessage['encrypted'],
|
||||
'iv' => $testMessage['iv'],
|
||||
]);
|
||||
|
||||
$this->info(' Created encrypted message for key verification');
|
||||
}
|
||||
|
||||
private function createCategories(User $user): void
|
||||
{
|
||||
(new CreateDefaultCategories)->handle($user);
|
||||
|
|
@ -234,16 +204,10 @@ class ResetDemoAccountCommand extends Command
|
|||
$createdAccounts = [];
|
||||
$transactionAccounts = [];
|
||||
|
||||
foreach ($accounts as $index => $accountData) {
|
||||
$encrypted = $this->encryptionService->encrypt(
|
||||
$accountData['name'],
|
||||
$this->encryptionKey,
|
||||
"demo_account_{$index}"
|
||||
);
|
||||
|
||||
foreach ($accounts as $accountData) {
|
||||
$account = $user->accounts()->create([
|
||||
'name' => $encrypted['encrypted'],
|
||||
'name_iv' => $encrypted['iv'],
|
||||
'name' => $accountData['name'],
|
||||
'name_iv' => null,
|
||||
'bank_id' => $accountData['bank_account_id'],
|
||||
'currency_code' => 'USD',
|
||||
'type' => $accountData['type'],
|
||||
|
|
@ -329,7 +293,6 @@ class ResetDemoAccountCommand extends Command
|
|||
}
|
||||
|
||||
$allTransactions = $this->transactionsProvider->getTransactions();
|
||||
$transactionIndex = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($allTransactions as $transactionData) {
|
||||
|
|
@ -344,14 +307,7 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
$account = $accounts[array_rand($accounts)];
|
||||
|
||||
$encrypted = $this->encryptionService->encrypt(
|
||||
$transactionData['description'],
|
||||
$this->encryptionKey,
|
||||
"demo_tx_{$account->id}_{$transactionIndex}"
|
||||
);
|
||||
|
||||
$transactionData['description'] = $encrypted['encrypted'];
|
||||
$transactionData['description_iv'] = $encrypted['iv'];
|
||||
$transactionData['description_iv'] = null;
|
||||
|
||||
$transaction = $account->transactions()->create([
|
||||
'user_id' => $account->user_id,
|
||||
|
|
@ -365,7 +321,6 @@ class ResetDemoAccountCommand extends Command
|
|||
}
|
||||
}
|
||||
|
||||
$transactionIndex++;
|
||||
$count++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ class HandleInertiaRequests extends Middleware
|
|||
'email' => config('app.demo.email'),
|
||||
'password' => config('app.demo.password'),
|
||||
] : null,
|
||||
'demoEncryptionKey' => $isDemoAccount ? config('app.demo.encryption_key') : null,
|
||||
'subscriptionsEnabled' => config('subscriptions.enabled', false),
|
||||
'pricing' => [
|
||||
'plans' => config('subscriptions.plans', []),
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Demo;
|
||||
|
||||
class DemoEncryptionService
|
||||
{
|
||||
private const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
private const SALT_LENGTH = 16;
|
||||
|
||||
private const IV_LENGTH = 12;
|
||||
|
||||
private const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Generate a deterministic salt from a seed string.
|
||||
*/
|
||||
public function generateSalt(string $seed = 'demo'): string
|
||||
{
|
||||
$salt = hash('sha256', $seed, true);
|
||||
|
||||
return base64_encode(substr($salt, 0, self::SALT_LENGTH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an AES key from password and salt using PBKDF2.
|
||||
*/
|
||||
public function deriveKey(string $password, string $saltBase64): string
|
||||
{
|
||||
$salt = base64_decode($saltBase64);
|
||||
|
||||
return hash_pbkdf2(
|
||||
'sha256',
|
||||
$password,
|
||||
$salt,
|
||||
self::PBKDF2_ITERATIONS,
|
||||
self::KEY_LENGTH,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext using AES-256-GCM.
|
||||
*
|
||||
* @return array{encrypted: string, iv: string}
|
||||
*/
|
||||
public function encrypt(string $plaintext, string $key, ?string $deterministicIvSeed = null): array
|
||||
{
|
||||
if ($deterministicIvSeed !== null) {
|
||||
$iv = substr(hash('sha256', $deterministicIvSeed, true), 0, self::IV_LENGTH);
|
||||
} else {
|
||||
$iv = random_bytes(self::IV_LENGTH);
|
||||
}
|
||||
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt(
|
||||
$plaintext,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
$encryptedWithTag = $encrypted.$tag;
|
||||
|
||||
return [
|
||||
'encrypted' => base64_encode($encryptedWithTag),
|
||||
'iv' => base64_encode($iv),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt ciphertext using AES-256-GCM.
|
||||
*/
|
||||
public function decrypt(string $encryptedBase64, string $key, string $ivBase64): string
|
||||
{
|
||||
$encryptedWithTag = base64_decode($encryptedBase64);
|
||||
$iv = base64_decode($ivBase64);
|
||||
|
||||
$tagLength = 16;
|
||||
$encrypted = substr($encryptedWithTag, 0, -$tagLength);
|
||||
$tag = substr($encryptedWithTag, -$tagLength);
|
||||
|
||||
$decrypted = openssl_decrypt(
|
||||
$encrypted,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($decrypted === false) {
|
||||
throw new \RuntimeException('Decryption failed');
|
||||
}
|
||||
|
||||
return $decrypted;
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +126,6 @@ return [
|
|||
'demo' => [
|
||||
'email' => env('DEMO_EMAIL', 'demo@whisper.money'),
|
||||
'password' => env('DEMO_PASSWORD', 'demo'),
|
||||
'encryption_key' => env('DEMO_ENCRYPTION_KEY', 'demo'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ class UserFactory extends Factory
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'onboarded_at' => now(),
|
||||
'encryption_salt' => 'test-salt',
|
||||
'locale' => 'en',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ it('formats amount on blur', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -33,7 +32,6 @@ it('accepts comma as decimal separator', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -55,7 +53,7 @@ it('can create a transaction with amount input', function () {
|
|||
|
||||
actingAs($user);
|
||||
|
||||
$page = $this->visitWithEncryptionKey('/transactions');
|
||||
$page = visit('/transactions');
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
|
|
@ -98,7 +96,6 @@ it('formats amount when pressing enter', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
|
|
@ -137,7 +134,6 @@ it('accepts negative amounts', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ it('can create an automation rule with visual builder', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
// Create category via UI to ensure it syncs to IndexedDB
|
||||
createCategoryViaUI($page, 'Groceries');
|
||||
|
|
@ -53,7 +52,6 @@ it('can add multiple conditions to a group', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Shopping');
|
||||
|
||||
|
|
@ -92,7 +90,6 @@ it('can add multiple groups', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Food');
|
||||
|
||||
|
|
@ -131,7 +128,6 @@ it('can select different field types and operators', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Bills');
|
||||
|
||||
|
|
@ -172,7 +168,6 @@ it('can edit an existing rule with visual builder', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Transport');
|
||||
|
||||
|
|
@ -217,7 +212,6 @@ it('validates that at least one condition is required', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Entertainment');
|
||||
|
||||
|
|
@ -248,7 +242,6 @@ it('can toggle group operators between AND and OR', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Health');
|
||||
|
||||
|
|
@ -290,7 +283,6 @@ it('can use is empty operator for nullable fields', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
createCategoryViaUI($page, 'Travel');
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,11 @@ it('can view bank accounts page', function () {
|
|||
|
||||
it('shows existing accounts in list', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'Test Bank']);
|
||||
$bank = Bank::factory()->create(['name' => 'Test Bank', 'logo' => null]);
|
||||
Account::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'name' => 'My Checking',
|
||||
'name_iv' => str_repeat('b', 16),
|
||||
'type' => 'checking',
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
|
@ -33,7 +32,7 @@ it('shows existing accounts in list', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->waitForText('Test Bank')
|
||||
|
|
@ -48,7 +47,6 @@ it('can open create account dialog', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->click('Create Account')
|
||||
|
|
@ -59,12 +57,11 @@ it('can open create account dialog', function () {
|
|||
|
||||
it('can create a new bank account', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'My Bank']);
|
||||
$bank = Bank::factory()->create(['name' => 'My Bank', 'logo' => null]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->click('Create Account')
|
||||
|
|
@ -109,24 +106,22 @@ it('shows empty state when no accounts exist', function () {
|
|||
|
||||
it('can filter accounts by name', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'Test Bank']);
|
||||
$bank = Bank::factory()->create(['name' => 'Test Bank', 'logo' => null]);
|
||||
Account::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'name' => 'Checking Account',
|
||||
'name_iv' => str_repeat('b', 16),
|
||||
]);
|
||||
Account::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'name' => 'Savings Account',
|
||||
'name_iv' => str_repeat('c', 16),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->waitForText('Test Bank')
|
||||
|
|
@ -137,18 +132,16 @@ it('can filter accounts by name', function () {
|
|||
|
||||
it('can edit an existing account via dropdown menu', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'Edit Bank']);
|
||||
$bank = Bank::factory()->create(['name' => 'Edit Bank', 'logo' => null]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$page->wait(1);
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
// Create account via UI to ensure it syncs to IndexedDB
|
||||
createAccountViaUI($page, 'Old Account Name', 'Edit Bank', 'Checking', 'USD');
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(5);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(5);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->click('button[aria-label="Open menu"]')
|
||||
|
|
@ -160,7 +153,7 @@ it('can edit an existing account via dropdown menu', function () {
|
|||
->click('button[type="submit"]:has-text("Update")')
|
||||
->wait(2);
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(5);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(5);
|
||||
|
||||
$page->assertSee('Updated Account Name')
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
@ -168,18 +161,16 @@ it('can edit an existing account via dropdown menu', function () {
|
|||
|
||||
it('can delete an account via dropdown menu', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'Delete Bank']);
|
||||
$bank = Bank::factory()->create(['name' => 'Delete Bank', 'logo' => null]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$page->wait(1);
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
// Create account via UI to ensure it syncs to IndexedDB
|
||||
createAccountViaUI($page, 'Account To Delete', 'Delete Bank', 'Checking', 'USD');
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(5);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(5);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->assertSee('Account To Delete')
|
||||
|
|
@ -193,7 +184,7 @@ it('can delete an account via dropdown menu', function () {
|
|||
->click('button[type="submit"]:has-text("Delete")')
|
||||
->wait(2);
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(5);
|
||||
$page->navigate('/settings/accounts', ['waitUntil' => 'domcontentloaded'])->wait(5);
|
||||
|
||||
$page->assertDontSee('Account To Delete')
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ it('can open import transactions drawer', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -30,7 +29,6 @@ it('shows no accounts message when none exist', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -48,7 +46,6 @@ it('can select account for import', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -67,7 +64,6 @@ it('can upload a CSV file for import', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -86,7 +82,6 @@ it('can complete full import flow', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -105,7 +100,6 @@ it('shows column mapping step after file upload', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -124,7 +118,6 @@ it('can navigate back through import steps', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
@ -143,7 +136,6 @@ it('applies automation rules when importing transactions', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('button[aria-label="More actions"]')
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ it('redirects onboarded user away from onboarding page to dashboard', function (
|
|||
it('redirects non-onboarded user from dashboard to onboarding', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
|
@ -95,7 +94,6 @@ it('navigates from welcome to account types', function () {
|
|||
it('shows existing accounts instead of create form when accounts exist', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$bank = Bank::factory()->create(['name' => 'Test Bank']);
|
||||
|
|
@ -125,7 +123,6 @@ it('shows existing accounts instead of create form when accounts exist', functio
|
|||
it('allows continuing with existing accounts', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$bank = Bank::factory()->create(['name' => 'Existing Bank']);
|
||||
|
|
@ -162,7 +159,6 @@ it('allows continuing with existing accounts', function () {
|
|||
it('shows import transactions step after account creation', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$bank = Bank::factory()->create(['name' => 'My Bank']);
|
||||
|
|
@ -192,7 +188,6 @@ it('shows import transactions step after account creation', function () {
|
|||
it('shows add another account form without first account restriction', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$bank = Bank::factory()->create(['name' => 'Primary Bank']);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ it('can open add transaction dialog', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -43,17 +42,12 @@ it('can create a transaction', function () {
|
|||
|
||||
actingAs($user);
|
||||
|
||||
// Generate a single encryption key to use throughout the test
|
||||
$encryptionKey = base64_encode(random_bytes(32));
|
||||
|
||||
// Create category via UI
|
||||
$page = visit('/settings/categories');
|
||||
$this->setupEncryptionKey($page, $encryptionKey);
|
||||
createCategoryViaUI($page, 'Groceries');
|
||||
|
||||
// Create account via UI
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page, $encryptionKey);
|
||||
createAccountViaUI($page, 'My Checking', 'Test Bank');
|
||||
|
||||
// Verify account was created
|
||||
|
|
@ -62,7 +56,6 @@ it('can create a transaction', function () {
|
|||
|
||||
// Visit transactions page
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page, $encryptionKey);
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
|
|
@ -111,7 +104,6 @@ it('can filter transactions by search text', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->wait(2)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ beforeEach(function () {
|
|||
config(['app.demo' => [
|
||||
'email' => 'demo@whisper.money',
|
||||
'password' => 'demo',
|
||||
'encryption_key' => 'demo',
|
||||
]]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,17 +43,6 @@ pest()->browser()->timeout(15000);
|
|||
|
|
||||
*/
|
||||
|
||||
function setupEncryptionKey($page, ?string $key = null): void
|
||||
{
|
||||
$key ??= base64_encode(random_bytes(32));
|
||||
$currentUrl = $page->url();
|
||||
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
|
||||
// Reload to trigger sync
|
||||
$page->navigate($currentUrl)->wait(1);
|
||||
// Reload again to ensure sync completes
|
||||
$page->navigate($currentUrl)->wait(3);
|
||||
}
|
||||
|
||||
function createCategoryViaUI($page, string $name, string $color = 'green', string $type = 'Expense'): void
|
||||
{
|
||||
$page->click('Create Category')
|
||||
|
|
|
|||
|
|
@ -12,61 +12,4 @@ abstract class TestCase extends BaseTestCase
|
|||
|
||||
config(['subscriptions.enabled' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a valid base64-encoded encryption key for testing.
|
||||
* This creates a 32-byte key (AES-256) and encodes it as base64.
|
||||
*/
|
||||
protected function generateTestEncryptionKey(): string
|
||||
{
|
||||
return base64_encode(random_bytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up encryption key in browser localStorage for testing.
|
||||
* This can be called before or after visiting a page.
|
||||
* If called after visiting, it will reload the page.
|
||||
*/
|
||||
protected function setupEncryptionKey($page, ?string $key = null, bool $reload = true): void
|
||||
{
|
||||
$key ??= $this->generateTestEncryptionKey();
|
||||
try {
|
||||
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
|
||||
} catch (\Exception) {
|
||||
// If script fails (page not ready), wait and retry
|
||||
$page->wait(0.2);
|
||||
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
|
||||
}
|
||||
if ($reload) {
|
||||
$currentUrl = $page->url();
|
||||
$page->navigate($currentUrl)->wait(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a page with encryption key already set up.
|
||||
*/
|
||||
protected function visitWithEncryptionKey(string $url, ?string $key = null)
|
||||
{
|
||||
$key ??= $this->generateTestEncryptionKey();
|
||||
$page = visit($url);
|
||||
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
|
||||
$page->navigate($url)->wait(1);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate a consistent test encryption key.
|
||||
* This ensures the same key is used across the test.
|
||||
*/
|
||||
protected function getTestEncryptionKey(): string
|
||||
{
|
||||
static $key = null;
|
||||
if ($key === null) {
|
||||
$key = $this->generateTestEncryptionKey();
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Demo\DemoEncryptionService;
|
||||
|
||||
test('generates deterministic salt from seed', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt1 = $service->generateSalt('demo');
|
||||
$salt2 = $service->generateSalt('demo');
|
||||
|
||||
expect($salt1)->toBe($salt2);
|
||||
expect(base64_decode($salt1))->toHaveLength(16);
|
||||
});
|
||||
|
||||
test('generates different salts for different seeds', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt1 = $service->generateSalt('demo');
|
||||
$salt2 = $service->generateSalt('other');
|
||||
|
||||
expect($salt1)->not->toBe($salt2);
|
||||
});
|
||||
|
||||
test('derives key from password and salt', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt = $service->generateSalt('demo');
|
||||
$key = $service->deriveKey('demo', $salt);
|
||||
|
||||
expect(strlen($key))->toBe(32);
|
||||
});
|
||||
|
||||
test('derives same key for same password and salt', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt = $service->generateSalt('demo');
|
||||
$key1 = $service->deriveKey('demo', $salt);
|
||||
$key2 = $service->deriveKey('demo', $salt);
|
||||
|
||||
expect($key1)->toBe($key2);
|
||||
});
|
||||
|
||||
test('encrypts and decrypts text correctly', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt = $service->generateSalt('demo');
|
||||
$key = $service->deriveKey('demo', $salt);
|
||||
|
||||
$plaintext = 'Hello, world';
|
||||
$encrypted = $service->encrypt($plaintext, $key);
|
||||
|
||||
expect($encrypted)->toHaveKeys(['encrypted', 'iv']);
|
||||
expect($encrypted['encrypted'])->not->toBe($plaintext);
|
||||
|
||||
$decrypted = $service->decrypt($encrypted['encrypted'], $key, $encrypted['iv']);
|
||||
|
||||
expect($decrypted)->toBe($plaintext);
|
||||
});
|
||||
|
||||
test('deterministic iv produces same encrypted output', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt = $service->generateSalt('demo');
|
||||
$key = $service->deriveKey('demo', $salt);
|
||||
|
||||
$plaintext = 'Hello, world';
|
||||
$encrypted1 = $service->encrypt($plaintext, $key, 'seed1');
|
||||
$encrypted2 = $service->encrypt($plaintext, $key, 'seed1');
|
||||
|
||||
expect($encrypted1['encrypted'])->toBe($encrypted2['encrypted']);
|
||||
expect($encrypted1['iv'])->toBe($encrypted2['iv']);
|
||||
});
|
||||
|
||||
test('different iv seeds produce different encrypted output', function () {
|
||||
$service = new DemoEncryptionService;
|
||||
|
||||
$salt = $service->generateSalt('demo');
|
||||
$key = $service->deriveKey('demo', $salt);
|
||||
|
||||
$plaintext = 'Hello, world';
|
||||
$encrypted1 = $service->encrypt($plaintext, $key, 'seed1');
|
||||
$encrypted2 = $service->encrypt($plaintext, $key, 'seed2');
|
||||
|
||||
expect($encrypted1['encrypted'])->not->toBe($encrypted2['encrypted']);
|
||||
});
|
||||
Loading…
Reference in New Issue