test(mcp): cover write tools, guardrails and token scope
Happy paths and guardrail failures for every write tool, the read-only token rejection (via a real read-only PAT so the tokenCan gate runs as it does over HTTP), cross-user isolation, the inherited Pro gate, and the read/read_write scope validation on token creation.
This commit is contained in:
parent
575e7a148d
commit
8fed5851bc
|
|
@ -0,0 +1,328 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Mcp\Servers\WhisperMoneyServer;
|
||||
use App\Mcp\Tools\CategorizeTransaction;
|
||||
use App\Mcp\Tools\CreateAutomationRule;
|
||||
use App\Mcp\Tools\CreateBalance;
|
||||
use App\Mcp\Tools\CreateCategory;
|
||||
use App\Mcp\Tools\CreateLabel;
|
||||
use App\Mcp\Tools\CreateTransaction;
|
||||
use App\Mcp\Tools\DeleteAutomationRule;
|
||||
use App\Mcp\Tools\DeleteCategory;
|
||||
use App\Mcp\Tools\DeleteLabel;
|
||||
use App\Mcp\Tools\DeleteTransaction;
|
||||
use App\Mcp\Tools\LabelTransaction;
|
||||
use App\Mcp\Tools\UpdateAutomationRule;
|
||||
use App\Mcp\Tools\UpdateCategory;
|
||||
use App\Mcp\Tools\UpdateLabel;
|
||||
use App\Mcp\Tools\UpdateTransaction;
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Laravel\Mcp\Server\Testing\TestResponse;
|
||||
|
||||
/**
|
||||
* Call a write tool as $user, giving them a real personal access token with the
|
||||
* given abilities so the tool's tokenCan('mcp:write') gate is exercised exactly
|
||||
* as it is over HTTP.
|
||||
*
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param list<string> $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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue