diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php new file mode 100644 index 00000000..1d22fbd5 --- /dev/null +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -0,0 +1,328 @@ + $arguments + * @param list $abilities + */ +function callWriteTool(User $user, string $tool, array $arguments = [], array $abilities = ['mcp:read', 'mcp:write']): TestResponse +{ + $user->withAccessToken($user->createToken('mcp', $abilities)->accessToken); + + return WhisperMoneyServer::actingAs($user)->tool($tool, $arguments); +} + +it('creates a manual transaction and defaults the currency to the account', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'EUR']); + + callWriteTool($user, CreateTransaction::class, [ + 'account_id' => $account->id, + 'description' => 'Blue Bottle Coffee', + 'amount' => -450, + 'transaction_date' => '2026-01-15', + ])->assertOk()->assertSee('Blue Bottle Coffee'); + + $transaction = Transaction::query()->where('account_id', $account->id)->first(); + + expect($transaction)->not->toBeNull(); + expect($transaction->description)->toBe('Blue Bottle Coffee'); + expect($transaction->currency_code)->toBe('EUR'); + expect($transaction->source->value)->toBe('manually_created'); +}); + +it('refuses to create a transaction on a connected account', function () { + $user = User::factory()->create(); + $account = Account::factory()->connected()->create(['user_id' => $user->id]); + + callWriteTool($user, CreateTransaction::class, [ + 'account_id' => $account->id, + 'description' => 'Nope', + 'amount' => -100, + 'transaction_date' => '2026-01-15', + ])->assertHasErrors(['connected']); + + expect(Transaction::query()->where('account_id', $account->id)->count())->toBe(0); +}); + +it('edits a manual transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'description' => 'Old description', + ]); + + callWriteTool($user, UpdateTransaction::class, [ + 'transaction_id' => $transaction->id, + 'description' => 'Fresh description', + ])->assertOk()->assertSee('Fresh description'); + + expect($transaction->fresh()->description)->toBe('Fresh description'); +}); + +it('refuses to edit an imported transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->imported()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + + callWriteTool($user, UpdateTransaction::class, [ + 'transaction_id' => $transaction->id, + 'description' => 'Hacked', + ])->assertHasErrors(['manually-created']); +}); + +it('deletes a manual transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + + callWriteTool($user, DeleteTransaction::class, [ + 'transaction_id' => $transaction->id, + ])->assertOk(); + + expect(Transaction::query()->find($transaction->id))->toBeNull(); +}); + +it('refuses to delete an imported transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->imported()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + + callWriteTool($user, DeleteTransaction::class, [ + 'transaction_id' => $transaction->id, + ])->assertHasErrors(['manually-created']); + + expect(Transaction::query()->find($transaction->id))->not->toBeNull(); +}); + +it('categorizes an imported transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->imported()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => null, + ]); + $category = Category::factory()->create(['user_id' => $user->id, 'name' => 'Dining']); + + callWriteTool($user, CategorizeTransaction::class, [ + 'transaction_id' => $transaction->id, + 'category_id' => $category->id, + ])->assertOk(); + + $transaction->refresh(); + + expect($transaction->category_id)->toBe($category->id); + expect($transaction->category_source)->toBe(CategorySource::Manual); +}); + +it('adds a label to an imported transaction', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $transaction = Transaction::factory()->imported()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + $label = Label::factory()->create(['user_id' => $user->id, 'name' => 'Reimbursable']); + + callWriteTool($user, LabelTransaction::class, [ + 'transaction_id' => $transaction->id, + 'add_label_ids' => [$label->id], + ])->assertOk()->assertSee('Reimbursable'); + + expect($transaction->fresh()->labels->pluck('id')->all())->toContain($label->id); +}); + +it('records a balance on a manual account', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + + callWriteTool($user, CreateBalance::class, [ + 'account_id' => $account->id, + 'balance' => 250000, + 'balance_date' => '2026-01-31', + ])->assertOk(); + + expect($account->balances()->whereDate('balance_date', '2026-01-31')->value('balance'))->toBe(250000); +}); + +it('refuses to record a balance on a connected account', function () { + $user = User::factory()->create(); + $account = Account::factory()->connected()->create(['user_id' => $user->id]); + + callWriteTool($user, CreateBalance::class, [ + 'account_id' => $account->id, + 'balance' => 250000, + ])->assertHasErrors(['connected']); + + expect($account->balances()->count())->toBe(0); +}); + +it('creates, updates and deletes a category', function () { + $user = User::factory()->create(); + + callWriteTool($user, CreateCategory::class, [ + 'name' => 'Travel', + 'icon' => 'Plane', + 'color' => 'blue', + 'type' => 'expense', + ])->assertOk()->assertSee('Travel'); + + $category = $user->categories()->where('name', 'Travel')->firstOrFail(); + + callWriteTool($user, UpdateCategory::class, [ + 'category_id' => $category->id, + 'name' => 'Holidays', + ])->assertOk()->assertSee('Holidays'); + + expect($category->fresh()->name)->toBe('Holidays'); + + callWriteTool($user, DeleteCategory::class, [ + 'category_id' => $category->id, + ])->assertOk(); + + expect(Category::query()->find($category->id))->toBeNull(); +}); + +it('creates, updates and deletes a label', function () { + $user = User::factory()->create(); + + callWriteTool($user, CreateLabel::class, [ + 'name' => 'Business', + 'color' => 'green', + ])->assertOk()->assertSee('Business'); + + $label = $user->labels()->where('name', 'Business')->firstOrFail(); + + callWriteTool($user, UpdateLabel::class, [ + 'label_id' => $label->id, + 'name' => 'Work', + ])->assertOk()->assertSee('Work'); + + expect($label->fresh()->name)->toBe('Work'); + + callWriteTool($user, DeleteLabel::class, [ + 'label_id' => $label->id, + ])->assertOk(); + + expect(Label::query()->find($label->id))->toBeNull(); +}); + +it('creates, updates and deletes an automation rule', function () { + $user = User::factory()->create(); + $category = Category::factory()->create(['user_id' => $user->id, 'name' => 'Groceries']); + + callWriteTool($user, CreateAutomationRule::class, [ + 'title' => 'Grocery rule', + 'priority' => 0, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $category->id, + ])->assertOk()->assertSee('Grocery rule'); + + $rule = $user->automationRules()->where('title', 'Grocery rule')->firstOrFail(); + + expect($rule->action_category_id)->toBe($category->id); + + callWriteTool($user, UpdateAutomationRule::class, [ + 'automation_rule_id' => $rule->id, + 'title' => 'Supermarket rule', + ])->assertOk()->assertSee('Supermarket rule'); + + expect($rule->fresh()->title)->toBe('Supermarket rule'); + + callWriteTool($user, DeleteAutomationRule::class, [ + 'automation_rule_id' => $rule->id, + ])->assertOk(); + + expect(AutomationRule::query()->find($rule->id))->toBeNull(); +}); + +it('requires an automation rule to have at least one action', function () { + $user = User::factory()->create(); + + callWriteTool($user, CreateAutomationRule::class, [ + 'title' => 'No action', + 'priority' => 0, + 'rules_json' => ['==' => [1, 1]], + ])->assertHasErrors(['action']); + + expect($user->automationRules()->count())->toBe(0); +}); + +it('rejects a write tool called with a read-only token', function () { + $user = User::factory()->create(); + + callWriteTool($user, CreateLabel::class, [ + 'name' => 'Should not exist', + 'color' => 'blue', + ], ['mcp:read'])->assertHasErrors(['read-only']); + + expect($user->labels()->count())->toBe(0); +}); + +it('still enforces the Pro-plan gate on write tools', function () { + config(['subscriptions.enabled' => true]); + $user = User::factory()->create(); + + callWriteTool($user, CreateLabel::class, [ + 'name' => 'Gated', + 'color' => 'blue', + ])->assertHasErrors(['Pro']); + + expect($user->labels()->count())->toBe(0); +}); + +it('never lets a write tool touch another user\'s data', function () { + $user = User::factory()->create(); + $other = User::factory()->create(); + $otherAccount = Account::factory()->create(['user_id' => $other->id]); + $otherTransaction = Transaction::factory()->create([ + 'user_id' => $other->id, + 'account_id' => $otherAccount->id, + ]); + + callWriteTool($user, DeleteTransaction::class, [ + 'transaction_id' => $otherTransaction->id, + ])->assertHasErrors(); + + expect(Transaction::query()->find($otherTransaction->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Settings/McpTokenTest.php b/tests/Feature/Settings/McpTokenTest.php index 39b4212a..e71c962b 100644 --- a/tests/Feature/Settings/McpTokenTest.php +++ b/tests/Feature/Settings/McpTokenTest.php @@ -38,7 +38,7 @@ it('creates a read-only token and flashes the secret once', function () { $user = mcpUser(); actingAs($user) - ->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop']) + ->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop', 'scope' => 'read']) ->assertRedirect(route('mcp.index')) ->assertSessionHas('mcp_token'); @@ -48,18 +48,39 @@ it('creates a read-only token and flashes the secret once', function () { expect($token->abilities)->toBe(['mcp:read']); }); +it('creates a read & write token carrying the mcp:write ability', function () { + $user = mcpUser(); + + actingAs($user) + ->post(route('mcp.tokens.store'), ['name' => 'Claude Code', 'scope' => 'read_write']) + ->assertRedirect(route('mcp.index')) + ->assertSessionHas('mcp_token'); + + expect($user->tokens()->first()->abilities)->toBe(['mcp:read', 'mcp:write']); +}); + it('requires a token name', function () { actingAs(mcpUser()) - ->post(route('mcp.tokens.store'), ['name' => '']) + ->post(route('mcp.tokens.store'), ['name' => '', 'scope' => 'read']) ->assertSessionHasErrors('name'); }); +it('requires a valid scope', function () { + actingAs(mcpUser()) + ->post(route('mcp.tokens.store'), ['name' => 'Bad', 'scope' => 'admin']) + ->assertSessionHasErrors('scope'); + + actingAs(mcpUser()) + ->post(route('mcp.tokens.store'), ['name' => 'Missing']) + ->assertSessionHasErrors('scope'); +}); + it('lets a free account create a token (gating happens at request time)', function () { config(['subscriptions.enabled' => true]); $user = mcpUser(); actingAs($user) - ->post(route('mcp.tokens.store'), ['name' => 'Free']) + ->post(route('mcp.tokens.store'), ['name' => 'Free', 'scope' => 'read']) ->assertSessionHas('mcp_token'); expect($user->tokens()->count())->toBe(1);