create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); actingAs($attacker); // Attacker tries to update victim's account balance $response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [ 'balance' => 9999999, ]); $response->assertForbidden(); $this->assertDatabaseMissing('account_balances', [ 'account_id' => $victimAccount->id, 'balance' => 9999999, ]); }); it('cannot update current balance for another user account', function () { $attacker = User::factory()->create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); actingAs($attacker); $response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [ 'balance' => 5000000, ]); $response->assertForbidden(); $this->assertDatabaseMissing('account_balances', [ 'account_id' => $victimAccount->id, 'balance' => 5000000, ]); }); it('cannot update existing balance for another user account', function () { $attacker = User::factory()->create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimBalance = AccountBalance::factory()->for($victimAccount)->create([ 'balance' => 1000000, ]); actingAs($attacker); $response = $this->putJson("/api/accounts/{$victimAccount->id}/balance/current", [ 'balance' => 9999999, ]); $response->assertForbidden(); $this->assertDatabaseHas('account_balances', [ 'id' => $victimBalance->id, 'balance' => 1000000, // Balance should remain unchanged ]); }); it('cannot delete balance from another user account', function () { $attacker = User::factory()->create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimBalance = AccountBalance::factory()->for($victimAccount)->create(); actingAs($attacker); $response = $this->deleteJson("/api/accounts/{$victimAccount->id}/balances/{$victimBalance->id}"); $response->assertForbidden(); $this->assertDatabaseHas('account_balances', [ 'id' => $victimBalance->id, ]); }); it('cannot list balances from another user account', function () { $attacker = User::factory()->create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); AccountBalance::factory()->for($victimAccount)->count(3)->create(); actingAs($attacker); $response = $this->getJson("/api/accounts/{$victimAccount->id}/balances"); $response->assertForbidden(); }); it('cannot delete balance using mismatched account and balance IDs', function () { $attacker = User::factory()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $victim = User::factory()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimBalance = AccountBalance::factory()->for($victimAccount)->create(); actingAs($attacker); // Attempt to delete victim's balance using attacker's account ID $response = $this->deleteJson("/api/accounts/{$attackerAccount->id}/balances/{$victimBalance->id}"); $response->assertNotFound(); $this->assertDatabaseHas('account_balances', [ 'id' => $victimBalance->id, ]); }); }); describe('Transaction IDOR Protection', function () { it('cannot create transaction for another user account', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); actingAs($attacker); $response = $this->post('/transactions', [ 'account_id' => $victimAccount->id, 'description' => 'Malicious transaction', 'description_iv' => str_repeat('a', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -50000, 'currency_code' => 'USD', 'source' => 'manual', ]); // The transaction might be created but should belong to attacker, not victim // OR it should fail if account validation includes ownership check if ($response->status() === 201) { $transaction = Transaction::latest()->first(); expect($transaction->user_id)->toBe($attacker->id) ->and($transaction->account_id)->not->toBe($victimAccount->id); } else { // If there's validation, it should fail with 422 or redirect with errors expect($response->status())->toBeIn([302, 422]); } }); it('cannot update transaction from another user', function () { $attacker = User::factory()->onboarded()->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransaction = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->create(); actingAs($attacker); $response = $this->patch("/transactions/{$victimTransaction->id}", [ 'category_id' => $attackerCategory->id, 'notes' => 'Hacked notes', 'notes_iv' => str_repeat('b', 16), ]); $response->assertForbidden(); $this->assertDatabaseHas('transactions', [ 'id' => $victimTransaction->id, 'category_id' => $victimCategory->id, 'user_id' => $victim->id, ]); $this->assertDatabaseMissing('transactions', [ 'id' => $victimTransaction->id, 'notes' => 'Hacked notes', ]); }); it('cannot delete transaction from another user', function () { $attacker = User::factory()->onboarded()->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimTransaction = Transaction::factory() ->for($victim) ->for($victimAccount) ->create(); actingAs($attacker); $response = $this->delete("/transactions/{$victimTransaction->id}"); $response->assertForbidden(); $this->assertDatabaseHas('transactions', [ 'id' => $victimTransaction->id, 'deleted_at' => null, ]); }); it('cannot bulk update transactions from another user by ID', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransactions = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->count(3) ->create(); actingAs($attacker); $response = $this->patch('/transactions/bulk', [ 'transaction_ids' => $victimTransactions->pluck('id')->toArray(), 'category_id' => $attackerCategory->id, ]); // Should return 403 because transaction IDs don't belong to attacker $response->assertForbidden(); // Verify victim's transactions remain unchanged foreach ($victimTransactions as $transaction) { $this->assertDatabaseHas('transactions', [ 'id' => $transaction->id, 'category_id' => $victimCategory->id, 'user_id' => $victim->id, ]); } }); it('cannot bulk update transactions from another user using filters', function () { $attacker = User::factory()->onboarded()->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransactions = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->count(5) ->create([ 'transaction_date' => now()->subDays(5), 'amount' => 10000, ]); actingAs($attacker); // Attempt to bulk update victim's transactions using date/amount filters $response = $this->patch('/transactions/bulk', [ 'filters' => [ 'date_from' => now()->subDays(10)->format('Y-m-d'), 'date_to' => now()->format('Y-m-d'), 'amount_min' => 50, 'amount_max' => 150, ], 'category_id' => $attackerCategory->id, ]); $response->assertOk(); // Bulk update should only affect attacker's own transactions (0 in this case) expect($response->json('count'))->toBe(0); // Verify victim's transactions remain unchanged foreach ($victimTransactions as $transaction) { $this->assertDatabaseHas('transactions', [ 'id' => $transaction->id, 'category_id' => $victimCategory->id, 'user_id' => $victim->id, ]); } }); it('bulk update only affects authenticated user transactions', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $attackerTransactions = Transaction::factory() ->for($attacker) ->for($attackerAccount) ->count(2) ->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransactions = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->count(3) ->create(); actingAs($attacker); // Update all transactions without filters $response = $this->patch('/transactions/bulk', [ 'category_id' => $attackerCategory->id, ]); $response->assertOk(); expect($response->json('count'))->toBe(2); // Only attacker's transactions // Verify only attacker's transactions were updated foreach ($attackerTransactions as $transaction) { $this->assertDatabaseHas('transactions', [ 'id' => $transaction->id, 'category_id' => $attackerCategory->id, ]); } // Verify victim's transactions remain unchanged foreach ($victimTransactions as $transaction) { $this->assertDatabaseHas('transactions', [ 'id' => $transaction->id, 'category_id' => $victimCategory->id, 'user_id' => $victim->id, ]); } }); }); describe('Cross-Resource IDOR Protection', function () { it('cannot use another user category when creating transaction', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimCategory = Category::factory()->for($victim)->create(); actingAs($attacker); $response = $this->post('/transactions', [ 'account_id' => $attackerAccount->id, 'category_id' => $victimCategory->id, // Attempt to use victim's category 'description' => 'Test transaction', 'description_iv' => str_repeat('a', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -10000, 'currency_code' => 'USD', 'source' => 'manual', ]); // Should either fail validation or ignore the category if ($response->status() === 201) { $transaction = Transaction::latest()->first(); expect($transaction->category_id)->not->toBe($victimCategory->id); } else { expect($response->status())->toBeIn([302, 422]); } }); it('cannot use another user category when updating transaction', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $attackerTransaction = Transaction::factory() ->for($attacker) ->for($attackerAccount) ->for($attackerCategory) ->create(); $victim = User::factory()->onboarded()->create(); $victimCategory = Category::factory()->for($victim)->create(); actingAs($attacker); $response = $this->patch("/transactions/{$attackerTransaction->id}", [ 'category_id' => $victimCategory->id, // Attempt to use victim's category ]); // Should fail validation with 422 or redirect with errors expect($response->status())->toBeIn([302, 422]); // Verify transaction category remains unchanged $attackerTransaction->refresh(); expect($attackerTransaction->category_id)->toBe($attackerCategory->id); }); it('cannot use another user account when creating transaction', function () { $attacker = User::factory()->onboarded()->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); actingAs($attacker); $response = $this->post('/transactions', [ 'account_id' => $victimAccount->id, // Attempt to use victim's account 'description' => 'Malicious transaction', 'description_iv' => str_repeat('a', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -50000, 'currency_code' => 'USD', 'source' => 'manual', ]); // Should either fail validation or the transaction should not be linked to victim's account if ($response->status() === 201) { $transaction = Transaction::latest()->first(); expect($transaction->account_id)->not->toBe($victimAccount->id); } else { expect($response->status())->toBeIn([302, 422]); } }); }); describe('Sync Endpoints IDOR Protection', function () { describe('TransactionSyncController', function () { it('cannot list transactions from another user', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerTransaction = Transaction::factory()->for($attacker)->for($attackerAccount)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimTransactions = Transaction::factory()->for($victim)->for($victimAccount)->count(3)->create(); actingAs($attacker); $response = $this->getJson('/api/sync/transactions'); $response->assertSuccessful(); $transactions = $response->json('data'); expect($transactions)->toBeArray(); expect(count($transactions))->toBe(1); expect($transactions[0]['id'])->toBe($attackerTransaction->id); expect($transactions[0]['id'])->not->toBeIn($victimTransactions->pluck('id')->toArray()); }); it('cannot list transactions from another user even with since parameter', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerTransaction = Transaction::factory()->for($attacker)->for($attackerAccount)->create([ 'updated_at' => now()->subDay(), ]); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimTransaction = Transaction::factory()->for($victim)->for($victimAccount)->create([ 'updated_at' => now(), ]); actingAs($attacker); $response = $this->getJson('/api/sync/transactions?since='.now()->subDays(2)->toISOString()); $response->assertSuccessful(); $transactions = $response->json('data'); expect($transactions)->toBeArray(); $transactionIds = collect($transactions)->pluck('id')->toArray(); expect($transactionIds)->toContain($attackerTransaction->id); expect($transactionIds)->not->toContain($victimTransaction->id); }); it('cannot create transaction for another user account via sync endpoint', function () { $attacker = User::factory()->onboarded()->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); actingAs($attacker); $response = $this->postJson('/api/sync/transactions', [ 'account_id' => $victimAccount->id, 'description' => 'Malicious transaction', 'description_iv' => str_repeat('a', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -50000, 'currency_code' => 'USD', 'source' => 'manual', ]); $response->assertUnprocessable(); expect($response->json('errors.account_id'))->toBeArray(); }); it('cannot create transaction using another user transaction ID via sync endpoint', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $attackerCategory = Category::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransaction = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->create(); actingAs($attacker); $response = $this->postJson('/api/sync/transactions', [ 'id' => $victimTransaction->id, 'account_id' => $attackerAccount->id, 'description' => 'Attempted ID reuse', 'description_iv' => str_repeat('a', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -50000, 'currency_code' => 'USD', 'source' => 'manually_created', ]); $response->assertStatus(500); $this->assertDatabaseHas('transactions', [ 'id' => $victimTransaction->id, 'user_id' => $victim->id, ]); $this->assertDatabaseMissing('transactions', [ 'id' => $victimTransaction->id, 'user_id' => $attacker->id, ]); }); it('idempotent create returns existing transaction when same user provides same ID', function () { $user = User::factory()->onboarded()->create(); $account = Account::factory()->for($user)->create(); $category = Category::factory()->for($user)->create(); $existingTransaction = Transaction::factory() ->for($user) ->for($account) ->for($category) ->create([ 'description' => 'Original transaction', 'description_iv' => str_repeat('a', 16), ]); actingAs($user); $response = $this->postJson('/api/sync/transactions', [ 'id' => $existingTransaction->id, 'account_id' => $account->id, 'description' => 'Attempted duplicate', 'description_iv' => str_repeat('b', 16), 'transaction_date' => now()->format('Y-m-d'), 'amount' => -50000, 'currency_code' => 'USD', 'source' => 'manually_created', ]); $response->assertSuccessful(); expect($response->json('data.id'))->toBe($existingTransaction->id); expect($response->json('data.description'))->toBe('Original transaction'); $this->assertDatabaseHas('transactions', [ 'id' => $existingTransaction->id, 'description' => 'Original transaction', ]); }); it('cannot update transaction from another user via sync endpoint', function () { $attacker = User::factory()->onboarded()->create(); $attackerAccount = Account::factory()->for($attacker)->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimCategory = Category::factory()->for($victim)->create(); $victimTransaction = Transaction::factory() ->for($victim) ->for($victimAccount) ->for($victimCategory) ->create(); actingAs($attacker); $response = $this->patchJson("/api/sync/transactions/{$victimTransaction->id}", [ 'account_id' => $attackerAccount->id, 'description' => 'Hacked transaction', 'description_iv' => str_repeat('b', 16), 'transaction_date' => $victimTransaction->transaction_date->format('Y-m-d'), 'amount' => $victimTransaction->amount, 'currency_code' => $victimTransaction->currency_code, 'source' => $victimTransaction->source->value, ]); $response->assertForbidden(); $this->assertDatabaseHas('transactions', [ 'id' => $victimTransaction->id, 'user_id' => $victim->id, 'account_id' => $victimAccount->id, ]); }); it('cannot delete transaction from another user via sync endpoint', function () { $attacker = User::factory()->onboarded()->create(); $victim = User::factory()->onboarded()->create(); $victimAccount = Account::factory()->for($victim)->create(); $victimTransaction = Transaction::factory() ->for($victim) ->for($victimAccount) ->create(); actingAs($attacker); $response = $this->deleteJson("/api/sync/transactions/{$victimTransaction->id}"); $response->assertForbidden(); $this->assertDatabaseHas('transactions', [ 'id' => $victimTransaction->id, 'deleted_at' => null, ]); }); }); });