Initial commit
This commit is contained in:
@@ -0,0 +1,734 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Financial;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Transaction Authorization Tests
|
||||
*
|
||||
* Tests who can create what types of transactions:
|
||||
* - User -> User: Work, Gift (no Donation, no Currency creation/removal)
|
||||
* - User -> Organization: Work, Gift, Donation
|
||||
* - Organization -> User: Work, Gift
|
||||
* - Organization -> Organization: Work, Gift, Donation
|
||||
* - Bank -> Any: Currency creation/removal, all other types
|
||||
* - Internal migrations (same accountable): Migration type enforced
|
||||
*
|
||||
* Tests account ownership validation and balance limits enforcement.
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group financial
|
||||
* @group transaction-authorization
|
||||
*/
|
||||
class TransactionAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// ACCOUNT OWNERSHIP TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that users can only create transactions from accounts they own
|
||||
*/
|
||||
public function test_user_can_only_create_transactions_from_owned_accounts()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$ownedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$unownedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Try to create transaction from unowned account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $unownedAccount->id,
|
||||
'to_account_id' => $ownedAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected (403 or validation error)
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that organizations can only use their own accounts
|
||||
*/
|
||||
public function test_organization_can_only_use_own_accounts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
|
||||
$user->organizations()->attach($org1->id);
|
||||
|
||||
$org1Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org1->id,
|
||||
]);
|
||||
|
||||
$org2Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org2->id,
|
||||
]);
|
||||
|
||||
// Login as organization1
|
||||
Auth::guard('web')->login($user);
|
||||
Auth::guard('organization')->login($org1);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Try to create transaction from org2's account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $org2Account->id,
|
||||
'to_account_id' => $org1Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized organization transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that banks can only use their managed accounts
|
||||
*/
|
||||
public function test_bank_can_only_use_managed_accounts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank1 = Bank::factory()->create();
|
||||
$bank2 = Bank::factory()->create();
|
||||
|
||||
$user->banksManaged()->attach($bank1->id);
|
||||
|
||||
$bank1Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank1->id,
|
||||
]);
|
||||
|
||||
$bank2Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank2->id,
|
||||
]);
|
||||
|
||||
// Login as bank1
|
||||
Auth::guard('web')->login($user);
|
||||
Auth::guard('bank')->login($bank1);
|
||||
session(['active_guard' => 'bank']);
|
||||
|
||||
// Try to create transaction from bank2's account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $bank2Account->id,
|
||||
'to_account_id' => $bank1Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized bank transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRANSACTION TYPE AUTHORIZATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test User -> User transactions allow Work and Gift types
|
||||
*/
|
||||
public function test_user_to_user_allows_work_and_gift()
|
||||
{
|
||||
$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,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Work type (ID 1) should be allowed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Work transaction',
|
||||
'transaction_type_id' => 1, // Work
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction1->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Gift type (ID 2) should be allowed
|
||||
$transaction2 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 30,
|
||||
'description' => 'Gift transaction',
|
||||
'transaction_type_id' => 2, // Gift
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction2->id,
|
||||
'transaction_type_id' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test User -> User transactions reject Donation type
|
||||
*/
|
||||
public function test_user_to_user_rejects_donation()
|
||||
{
|
||||
$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,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Donation type (ID 3) should be rejected for User -> User
|
||||
// This should be caught by application logic, not just database constraint
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid donation',
|
||||
'transaction_type_id' => 3, // Donation
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
// Exception is also acceptable
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test User -> Organization allows Donation type
|
||||
*/
|
||||
public function test_user_to_organization_allows_donation()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Donation type (ID 3) should be allowed for User -> Organization
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $orgAccount->id,
|
||||
'amount' => 120,
|
||||
'description' => 'Donation to organization',
|
||||
'transaction_type_id' => 3, // Donation
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'transaction_type_id' => 3,
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $orgAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that regular users cannot create currency creation transactions
|
||||
*/
|
||||
public function test_users_cannot_create_currency_creation_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,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Currency creation type (ID 4) should be rejected for regular users
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid currency creation',
|
||||
'transaction_type_id' => 4, // Currency creation
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that regular users cannot create currency removal transactions
|
||||
*/
|
||||
public function test_users_cannot_create_currency_removal_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,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Currency removal type (ID 5) should be rejected for regular users
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid currency removal',
|
||||
'transaction_type_id' => 5, // Currency removal
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INTERNAL MIGRATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that internal migrations (same accountable) use Migration type
|
||||
*/
|
||||
public function test_internal_migration_uses_migration_type()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create two accounts for the same user
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'name' => 'time',
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'name' => 'savings',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Internal transfer should automatically use Migration type (ID 6)
|
||||
// Even if user tries to specify different type, it should be overridden
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Transfer between own accounts',
|
||||
'transaction_type_id' => 1, // User specifies Work, but should be changed to Migration
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Verify transaction was created with Migration type
|
||||
// Note: This depends on TransactionController logic enforcing type 6 for internal transfers
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
]);
|
||||
|
||||
// The transaction_type_id should be 6 (Migration) if controller logic is correct
|
||||
// This test documents expected behavior
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BALANCE LIMIT ENFORCEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions respect sender's minimum balance limit
|
||||
*/
|
||||
public function test_transaction_respects_sender_minimum_limit()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'limit_min' => -100, // Can go 100 minutes into negative
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Current balance: 0
|
||||
// Minimum allowed: -100
|
||||
// Transfer budget: 0 - (-100) = 100 minutes
|
||||
|
||||
// Transaction of 100 minutes should succeed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Max allowed payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
|
||||
|
||||
// Transaction of 1 more minute should fail (would exceed limit)
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 1,
|
||||
'description' => 'Over limit payment',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions respect receiver's maximum balance limit
|
||||
*/
|
||||
public function test_transaction_respects_receiver_maximum_limit()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'limit_min' => -1000, // Large sending capacity
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
'limit_min' => 0,
|
||||
'limit_max' => 100, // Can only receive up to 100 minutes
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Receiver current balance: 0
|
||||
// Receiver maximum allowed: 100
|
||||
// Receiver can accept: 100 - 0 = 100 minutes
|
||||
|
||||
// Transaction of 100 minutes should succeed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Max receivable payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
|
||||
|
||||
// Transaction of 1 more minute should fail (would exceed receiver's limit)
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 1,
|
||||
'description' => 'Over receiver limit payment',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that organizations have higher balance limits than users
|
||||
*/
|
||||
public function test_organizations_have_higher_limits_than_users()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
// Get limits from configuration
|
||||
$userLimitMax = timebank_config('accounts.user.limit_max');
|
||||
$userLimitMin = timebank_config('accounts.user.limit_min');
|
||||
$orgLimitMax = timebank_config('accounts.organization.limit_max');
|
||||
$orgLimitMin = timebank_config('accounts.organization.limit_min');
|
||||
|
||||
// Organizations should have higher limits
|
||||
$this->assertGreaterThan($userLimitMax, $orgLimitMax,
|
||||
'Organizations should have higher maximum balance limit than users');
|
||||
|
||||
// Organizations should be able to go more negative (give more)
|
||||
$this->assertLessThan($userLimitMin, $orgLimitMin,
|
||||
'Organizations should be able to go more negative than users');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DELETED/INACTIVE ACCOUNT PROTECTION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created from deleted accounts
|
||||
*/
|
||||
public function test_cannot_create_transaction_from_deleted_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$deletedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'deleted_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction from deleted account
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $deletedAccount->id,
|
||||
'to_account_id' => $activeAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'From deleted account',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created to deleted accounts
|
||||
*/
|
||||
public function test_cannot_create_transaction_to_deleted_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$deletedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
'deleted_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction to deleted account
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $activeAccount->id,
|
||||
'to_account_id' => $deletedAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'To deleted account',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created from/to inactive accountables
|
||||
*/
|
||||
public function test_cannot_create_transaction_to_inactive_accountable()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create(['inactive_at' => now()->subDay()]);
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$inactiveAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction to account of inactive user
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $activeAccount->id,
|
||||
'to_account_id' => $inactiveAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'To inactive user',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRANSACTION TYPE EXISTENCE TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that all required transaction types exist in database
|
||||
*/
|
||||
public function test_all_transaction_types_exist()
|
||||
{
|
||||
// Required transaction types:
|
||||
// 1. Work
|
||||
// 2. Gift
|
||||
// 3. Donation
|
||||
// 4. Currency creation
|
||||
// 5. Currency removal
|
||||
// 6. Migration
|
||||
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 1, 'name' => 'Work']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 2, 'name' => 'Gift']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 3, 'name' => 'Donation']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 4, 'name' => 'Currency creation']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 5, 'name' => 'Currency removal']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 6, 'name' => 'Migration']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions require valid transaction type
|
||||
*/
|
||||
public function test_transaction_requires_valid_type()
|
||||
{
|
||||
$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,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Non-existent transaction type should be rejected
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid type',
|
||||
'transaction_type_id' => 999, // Non-existent type
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
664
tests/Feature/Security/Financial/TransactionIntegrityTest.php
Normal file
664
tests/Feature/Security/Financial/TransactionIntegrityTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user