Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,664 @@
<?php
namespace Tests\Feature\Security\Financial;
use App\Models\Account;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Transaction Integrity Tests
*
* Tests the database-level immutability and integrity of transactions:
* - Transactions can only be created (INSERT) and read (SELECT)
* - Transactions cannot be modified (UPDATE) or deleted (DELETE)
* - Balance calculations use window functions correctly
* - Concurrent transaction handling works properly
* - Transaction validation rules are enforced
*
* CRITICAL FINANCIAL SECURITY: Transaction immutability is enforced at MySQL
* user permission level. These tests verify that enforcement.
*
* @group security
* @group critical
* @group financial
* @group transaction-integrity
*/
class TransactionIntegrityTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// DATABASE IMMUTABILITY TESTS
// ==========================================
/**
* Test that transactions can be created (INSERT allowed)
*/
public function test_transactions_can_be_created()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60, // 1 hour
'description' => 'Test payment',
'transaction_type_id' => 1, // Work
'creator_user_id' => $user->id,
]);
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
]);
}
/**
* Test that transactions can be read (SELECT allowed)
*/
public function test_transactions_can_be_read()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 120, // 2 hours
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$retrieved = Transaction::find($transaction->id);
$this->assertNotNull($retrieved);
$this->assertEquals(120, $retrieved->amount);
$this->assertEquals('Test payment', $retrieved->description);
}
/**
* Test that attempting to update transactions via Eloquent throws exception
*
* Note: This tests application-level protection. Database-level protection
* (MySQL user permissions) should also prevent UPDATE at DB level.
*/
public function test_transactions_cannot_be_updated_via_eloquent()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Original description',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$originalAmount = $transaction->amount;
$originalDescription = $transaction->description;
// Try to modify the transaction
$transaction->amount = 120;
$transaction->description = 'Modified description';
// If save() succeeds, verify data hasn't changed in database
// If MySQL permissions are correctly set, save() should fail
try {
$transaction->save();
// If save succeeded, check if data actually changed
$transaction->refresh();
// Transaction should remain unchanged (either save failed silently or DB rejected UPDATE)
$this->assertEquals($originalAmount, $transaction->amount,
'Transaction amount should not be modifiable');
$this->assertEquals($originalDescription, $transaction->description,
'Transaction description should not be modifiable');
} catch (\Exception $e) {
// Expected: UPDATE permission denied
$this->assertTrue(true, 'Transaction update correctly prevented');
}
}
/**
* Test that attempting to delete transactions via Eloquent throws exception
*
* Note: This tests application-level protection. Database-level protection
* (MySQL user permissions) should also prevent DELETE at DB level.
*/
public function test_transactions_cannot_be_deleted_via_eloquent()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$transactionId = $transaction->id;
// Try to delete the transaction
try {
$transaction->delete();
// If delete succeeded, verify transaction still exists
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
'Transaction should not be deletable');
} catch (\Exception $e) {
// Expected: DELETE permission denied
$this->assertTrue(true, 'Transaction deletion correctly prevented');
}
}
/**
* Test that raw SQL UPDATE is prevented by database permissions
*
* This test verifies the MySQL user permission restrictions.
*/
public function test_raw_sql_update_is_prevented()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Original description',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$originalAmount = $transaction->amount;
// Try to update via raw SQL
try {
DB::statement('UPDATE transactions SET amount = ? WHERE id = ?', [120, $transaction->id]);
// If update succeeded, verify data hasn't changed
$transaction->refresh();
$this->assertEquals($originalAmount, $transaction->amount,
'Raw SQL UPDATE should not modify transaction');
} catch (\Exception $e) {
// Expected behavior: Permission denied
$this->assertStringContainsString('UPDATE command denied', $e->getMessage());
}
}
/**
* Test that raw SQL DELETE is prevented by database permissions
*/
public function test_raw_sql_delete_is_prevented()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$transactionId = $transaction->id;
// Try to delete via raw SQL
try {
DB::statement('DELETE FROM transactions WHERE id = ?', [$transactionId]);
// If delete succeeded, verify transaction still exists
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
'Raw SQL DELETE should not remove transaction');
} catch (\Exception $e) {
// Expected behavior: Permission denied
$this->assertStringContainsString('DELETE command denied', $e->getMessage());
}
}
// ==========================================
// BALANCE CALCULATION INTEGRITY TESTS
// ==========================================
/**
* Test that balance calculations are correct for single transaction
*/
public function test_balance_calculation_single_transaction()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Initial balances should be 0
$this->assertEquals(0, $account1->balance);
$this->assertEquals(0, $account2->balance);
// Create transaction: user1 pays user2 60 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Clear cache to force recalculation
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
// Refresh models
$account1 = $account1->fresh();
$account2 = $account2->fresh();
// user1 should have -60, user2 should have +60
$this->assertEquals(-60, $account1->balance);
$this->assertEquals(60, $account2->balance);
}
/**
* Test that balance calculations are correct for multiple transactions
*/
public function test_balance_calculation_multiple_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Transaction 1: user1 pays user2 60 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Payment 1',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Transaction 2: user2 pays user1 30 minutes
Transaction::create([
'from_account_id' => $account2->id,
'to_account_id' => $account1->id,
'amount' => 30,
'description' => 'Payment 2',
'transaction_type_id' => 1,
'creator_user_id' => $user2->id,
]);
// Transaction 3: user1 pays user2 15 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 15,
'description' => 'Payment 3',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Clear cache
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
$account1 = $account1->fresh();
$account2 = $account2->fresh();
// user1: -60 + 30 - 15 = -45
// user2: +60 - 30 + 15 = +45
$this->assertEquals(-45, $account1->balance);
$this->assertEquals(45, $account2->balance);
}
/**
* Test that balance calculations handle concurrent transactions correctly
*/
public function test_balance_calculation_with_concurrent_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user3 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$account3 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user3->id,
]);
// Simulate multiple transactions happening "simultaneously"
DB::transaction(function () use ($account1, $account2, $account3, $user1, $user2, $user3) {
// Multiple transfers from account1
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 30,
'description' => 'Concurrent payment 1',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account3->id,
'amount' => 20,
'description' => 'Concurrent payment 2',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
Transaction::create([
'from_account_id' => $account2->id,
'to_account_id' => $account1->id,
'amount' => 10,
'description' => 'Concurrent payment 3',
'transaction_type_id' => 1,
'creator_user_id' => $user2->id,
]);
});
// Clear cache
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
\Cache::forget("account_balance_{$account3->id}");
$account1 = $account1->fresh();
$account2 = $account2->fresh();
$account3 = $account3->fresh();
// account1: -30 - 20 + 10 = -40
// account2: +30 - 10 = +20
// account3: +20 = +20
$this->assertEquals(-40, $account1->balance);
$this->assertEquals(20, $account2->balance);
$this->assertEquals(20, $account3->balance);
// Sum of all balances should be 0 (zero-sum system)
$totalBalance = $account1->balance + $account2->balance + $account3->balance;
$this->assertEquals(0, $totalBalance, 'Total balance in system should always be zero');
}
// ==========================================
// TRANSACTION VALIDATION TESTS
// ==========================================
/**
* Test that transactions require valid from_account_id
*/
public function test_transaction_requires_valid_from_account()
{
$user = User::factory()->create();
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => 99999, // Non-existent account
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that transactions require valid to_account_id
*/
public function test_transaction_requires_valid_to_account()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => 99999, // Non-existent account
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that transactions require positive amount
*/
public function test_transaction_requires_positive_amount()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
// Negative amounts should be rejected
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => -60, // Negative amount
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that from_account and to_account must be different
*/
public function test_transaction_requires_different_accounts()
{
$user = User::factory()->create();
$account = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $account->id,
'to_account_id' => $account->id, // Same as from_account
'amount' => 60,
'description' => 'Self-payment test',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
// ==========================================
// ZERO-SUM INTEGRITY TESTS
// ==========================================
/**
* Test that the system maintains zero-sum integrity
*
* In a timebanking system, the sum of all account balances
* should always equal zero (money is neither created nor destroyed
* except through special currency creation/removal transactions).
*/
public function test_system_maintains_zero_sum_integrity()
{
// Create multiple users and accounts
$users = User::factory()->count(5)->create();
$accounts = $users->map(function ($user) {
return Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
});
// Create random transactions between accounts
for ($i = 0; $i < 10; $i++) {
$fromAccount = $accounts->random();
$toAccount = $accounts->where('id', '!=', $fromAccount->id)->random();
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => rand(10, 100),
'description' => "Random transaction {$i}",
'transaction_type_id' => 1,
'creator_user_id' => $users->random()->id,
]);
}
// Clear all balance caches
foreach ($accounts as $account) {
\Cache::forget("account_balance_{$account->id}");
}
// Calculate sum of all balances
$totalBalance = $accounts->sum(function ($account) {
return $account->fresh()->balance;
});
// Should be exactly 0 (zero-sum system)
$this->assertEquals(0, $totalBalance,
'System should maintain zero-sum integrity: sum of all balances must be 0');
}
/**
* Test that transaction creation maintains database consistency
*/
public function test_transaction_creation_maintains_consistency()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$transactionCountBefore = Transaction::count();
// Create transaction in database transaction
DB::transaction(function () use ($account1, $account2, $user1) {
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Consistency test',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
});
$transactionCountAfter = Transaction::count();
// Verify transaction was created
$this->assertEquals($transactionCountBefore + 1, $transactionCountAfter);
// Verify transaction is retrievable
$transaction = Transaction::latest()->first();
$this->assertNotNull($transaction);
$this->assertEquals($account1->id, $transaction->from_account_id);
$this->assertEquals($account2->id, $transaction->to_account_id);
$this->assertEquals(60, $transaction->amount);
}
}