diff --git a/.ai/lessons.md b/.ai/lessons.md index de73b119be..e0f315ae8e 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -5,6 +5,7 @@ - When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround. - Test old reports against the current branch because later changes can make the report obsolete. - Use the same regression test before and after the production change so the result shows the behavior difference. +- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. ## Verify the complete user flow - Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. @@ -49,3 +50,6 @@ ## Fail closed at public webhook boundaries - Reject missing or blank secrets before signature verification, and return generic errors without logging secrets, signatures, or payloads. + +## Pass identities to Livewire actions +- Pass record IDs to Livewire actions instead of display values, and resolve team-scoped records on the server. When JavaScript needs text, use `@js()` or `Js::from()`. diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 61d04e20b5..6b3493ace9 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -67,17 +67,17 @@ class Tags extends Component } } - public function addTag(string $id, string $name) + public function addTag(string $id) { try { $this->authorize('update', $this->resource); - $name = strip_tags($name); - if ($this->resource->tags()->where('id', $id)->exists()) { - $this->dispatch('error', 'Duplicate tags.', "Tag $name already added."); + $tag = Tag::ownedByCurrentTeam()->findOrFail($id); + if ($this->resource->tags()->whereKey($tag->id)->exists()) { + $this->dispatch('error', 'Duplicate tags.', 'Tag '.e($tag->name).' already added.'); return; } - $this->resource->tags()->attach($id); + $this->resource->tags()->attach($tag->id); $this->refresh(); $this->dispatch('success', 'Tag added.'); } catch (\Exception $e) { diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php index 1fb1c9d6ad..1debd351b1 100644 --- a/resources/views/livewire/project/shared/tags.blade.php +++ b/resources/views/livewire/project/shared/tags.blade.php @@ -52,7 +52,7 @@
@foreach ($filteredTags as $tag) + wire:click="addTag('{{ $tag->id }}')"> {{ $tag->name }} diff --git a/tests/Feature/TagQuickAddTest.php b/tests/Feature/TagQuickAddTest.php new file mode 100644 index 0000000000..1289268aef --- /dev/null +++ b/tests/Feature/TagQuickAddTest.php @@ -0,0 +1,81 @@ + 0]); + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); +}); + +test('quick add passes only the tag id to the action', function () { + $name = "o'reilly"; + Livewire::test(Tags::class, ['resource' => $this->application]) + ->set('newTags', $name) + ->call('submit') + ->assertNotDispatched('error'); + + $tag = Tag::ownedByCurrentTeam()->where('name', $name)->firstOrFail(); + $otherApplication = Application::factory()->create([ + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + ]); + + $html = Livewire::test(Tags::class, ['resource' => $otherApplication])->html(); + expect($html)->toContain("wire:click=\"addTag('{$tag->id}')\"") + ->not->toMatch('/wire:click="[^"]*reilly/'); +}); + +test('addTag accepts only a tag owned by the current team', function () { + $ownTag = Tag::create(['name' => 'own-tag', 'team_id' => $this->team->id]); + $otherTag = Tag::create(['name' => 'other-tag', 'team_id' => Team::factory()->create()->id]); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->call('addTag', (string) $otherTag->id); + expect($this->application->tags()->whereKey($otherTag->id)->exists())->toBeFalse(); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->call('addTag', (string) $ownTag->id); + expect($this->application->tags()->whereKey($ownTag->id)->exists())->toBeTrue(); +}); + +test('member cannot create a tag through resource settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Tags::class, ['resource' => $this->application]) + ->set('newTags', 'member-tag') + ->call('submit') + ->assertDispatched('error'); + expect(Tag::where('name', 'member-tag')->exists())->toBeFalse(); +}); diff --git a/tests/v4/Browser/TagQuickAddTest.php b/tests/v4/Browser/TagQuickAddTest.php new file mode 100644 index 0000000000..87b0e64bae --- /dev/null +++ b/tests/v4/Browser/TagQuickAddTest.php @@ -0,0 +1,35 @@ + 'Source App']); + $target = createBrowserApplication($stack, ['name' => 'Target App']); + $source->tags()->attach($tag = Tag::create(['name' => "o'reilly", 'team_id' => 0])); + + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate(route('project.application.tags', [ + 'project_uuid' => $stack['project']->uuid, + 'environment_uuid' => $stack['environment']->uuid, + 'application_uuid' => $target->uuid, + ])) + ->assertSee("o'reilly"); + + $page->script(<<<'JS' + () => [...document.querySelectorAll('#available-tags-section button')] + .find(button => button.textContent.includes('reilly')) + .click() + JS); + $page->wait(1.5); + + expect($target->tags()->whereKey($tag->id)->exists())->toBeTrue(); + $page->screenshot(filename: 'tag-quick-add'); +});