whisper-money/tests/Feature/OpenBanking/ConnectionControllerTest.php

446 lines
16 KiB
PHP

<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config([
'services.enablebanking.app_id' => 'test-app-id',
'services.enablebanking.private_key_path' => '/tmp/fake-key.pem',
'services.enablebanking.redirect_url' => 'https://example.com/callback',
]);
});
test('users can view their connections page', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/connections')
->has('connections', 1)
);
});
test('connections page only shows own connections', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
BankingConnection::factory()->create(['user_id' => $user->id]);
BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 1)
);
});
test('users can disconnect a banking connection and keep accounts as manual', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$balance = AccountBalance::factory()->create([
'account_id' => $account->id,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once();
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => false,
'confirmation' => null,
]);
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
$account->refresh();
expect($account->banking_connection_id)->toBeNull();
expect($account->external_account_id)->toBeNull();
expect($account->trashed())->toBeFalse();
expect(Transaction::find($transaction->id))->not->toBeNull();
expect(AccountBalance::find($balance->id))->not->toBeNull();
});
test('users can disconnect a banking connection and delete accounts', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$balance = AccountBalance::factory()->create([
'account_id' => $account->id,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once();
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => true,
'confirmation' => 'delete all',
]);
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
expect(Account::withTrashed()->find($account->id)->trashed())->toBeTrue();
expect(Transaction::withTrashed()->find($transaction->id)->trashed())->toBeTrue();
expect(AccountBalance::find($balance->id))->toBeNull();
});
test('deleting accounts requires confirmation text', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => true,
'confirmation' => 'wrong text',
]);
$response->assertSessionHasErrors('confirmation');
expect($connection->fresh()->trashed())->toBeFalse();
});
test('users cannot disconnect another users connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => false,
]);
$response->assertForbidden();
});
test('users can trigger manual sync on active connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'status' => BankingConnectionStatus::Active,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect();
$response->assertSessionHas('success');
});
test('free tier users are redirected to subscribe when syncing a connection after onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'status' => BankingConnectionStatus::Active,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect(route('subscribe'));
Queue::assertNothingPushed();
});
test('disconnecting indexa capital connection does not revoke session', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('revokeSession');
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => false,
'confirmation' => null,
]);
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
$account->refresh();
expect($account->banking_connection_id)->toBeNull();
expect($account->external_account_id)->toBeNull();
});
test('users cannot sync expired connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect();
$response->assertSessionHas('error');
});
test('users can update indexa capital credentials with valid token', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual']],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'new-valid-indexa-token-12345',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->error_message)->toBeNull();
expect($connection->api_token)->toBe('new-valid-indexa-token-12345');
});
test('free tier users are redirected to subscribe when updating credentials after onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'new-valid-indexa-token-12345',
]);
$response->assertRedirect(route('subscribe'));
Queue::assertNothingPushed();
});
test('users can update binance credentials with valid api key and secret', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->binance()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
Http::fake([
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0']],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_key' => 'new-valid-binance-key-12345',
'api_secret' => 'new-valid-binance-secret-12345',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->error_message)->toBeNull();
expect($connection->api_token)->toBe('new-valid-binance-key-12345');
expect($connection->api_secret)->toBe('new-valid-binance-secret-12345');
});
test('users can update bitpanda credentials with valid api key', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->bitpanda()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_key' => 'new-valid-bitpanda-key-12345',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->error_message)->toBeNull();
expect($connection->api_token)->toBe('new-valid-bitpanda-key-12345');
});
test('updating credentials with invalid token returns validation error', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id,
]);
Http::fake([
'api.indexacapital.com/users/me' => Http::response(['error' => 'Unauthorized'], 401),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'invalid-token-12345678',
]);
$response->assertSessionHasErrors('credentials');
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Error);
});
test('cannot update credentials for enablebanking connection', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'provider' => 'enablebanking',
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'some-token-value-12345',
]);
$response->assertSessionHasErrors();
});
test('cannot update credentials for another users connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $otherUser->id,
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'some-token-value-12345',
]);
$response->assertForbidden();
});
test('users can update credentials for their own connection', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
]);
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'some-token-value-12345',
]);
$response->assertRedirect();
});
test('credential update validates required fields for binance', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->binance()->error()->create([
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_key' => 'valid-key-12345678',
// missing api_secret
]);
$response->assertSessionHasErrors('api_secret');
});
test('connections page includes provider and aspsp_name fields needed for frontend duplicate filtering', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->create([
'user_id' => $user->id,
'provider' => 'enablebanking',
'aspsp_name' => 'CaixaBank',
]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 1)
->where('connections.0.provider', 'enablebanking')
->where('connections.0.aspsp_name', 'CaixaBank')
);
});
test('connections page includes connections from all provider types for frontend filtering', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->create(['user_id' => $user->id, 'provider' => 'enablebanking', 'aspsp_name' => 'CaixaBank']);
BankingConnection::factory()->binance()->create(['user_id' => $user->id]);
BankingConnection::factory()->bitpanda()->create(['user_id' => $user->id]);
BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 4)
->has('connections.0.provider')
->has('connections.0.aspsp_name')
);
});