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:
Víctor Falcón 2026-02-17 11:45:27 +01:00 committed by GitHub
parent f8e8fc4db1
commit a53e2be57b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 18 additions and 364 deletions

View File

@ -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++;
}

View File

@ -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', []),

View File

@ -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;
}
}

View File

@ -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'),
],
];

View File

@ -64,7 +64,6 @@ class UserFactory extends Factory
{
return $this->state(fn (array $attributes) => [
'onboarded_at' => now(),
'encryption_salt' => 'test-salt',
'locale' => 'en',
]);
}

View File

@ -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')

View File

@ -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');

View File

@ -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();

View File

@ -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"]')

View File

@ -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']);

View File

@ -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)

View File

@ -6,7 +6,6 @@ beforeEach(function () {
config(['app.demo' => [
'email' => 'demo@whisper.money',
'password' => 'demo',
'encryption_key' => 'demo',
]]);
});

View File

@ -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')

View File

@ -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;
}
}

View File

@ -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']);
});