fix(auth): enforce authorization checks in Livewire components
- Replace manual ownership checks with authorize() in Destination/Show, NavbarDeleteTeam, and Project/Show - Add authorization checks for team deletion and environment creation - Add proper exception handling with try-catch blocks - Add comprehensive feature and browser tests for authorization scenarios - Update CLAUDE.md with Pest Browser Plugin testing guidelines
This commit is contained in:
parent
41e1248b6f
commit
94dfd6a54e
8 changed files with 498 additions and 27 deletions
37
CLAUDE.md
37
CLAUDE.md
|
|
@ -34,6 +34,43 @@ # Frontend
|
|||
npm run build # production build
|
||||
```
|
||||
|
||||
## Browser Tests (Pest Browser Plugin)
|
||||
|
||||
Uses `pestphp/pest-plugin-browser` with Laravel Dusk 8. New browser tests go in `tests/v4/Browser/`.
|
||||
|
||||
```bash
|
||||
# Run all browser tests
|
||||
php artisan test --compact tests/v4/Browser/
|
||||
|
||||
# Run a specific browser test file
|
||||
php artisan test --compact tests/v4/Browser/LoginTest.php
|
||||
|
||||
# Run a specific test by name
|
||||
php artisan test --compact --filter='can login with valid credentials'
|
||||
```
|
||||
|
||||
### Writing Browser Tests
|
||||
|
||||
- Place new tests in `tests/v4/Browser/` — legacy Dusk tests in `tests/Browser/` should not be used as reference.
|
||||
- Use `RefreshDatabase` and seed required data (at minimum `InstanceSettings::create(['id' => 0])`) in `beforeEach`.
|
||||
- Key API: `visit()`, `fill(field, value)`, `click(text)`, `assertSee()`, `assertDontSee()`, `assertPathIs()`, `screenshot()`.
|
||||
- Always call `screenshot()` at the end of each test for debugging.
|
||||
- For authenticated tests, create a helper function that logs in via the UI:
|
||||
|
||||
```php
|
||||
function loginAsRoot(): mixed
|
||||
{
|
||||
return visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login');
|
||||
}
|
||||
```
|
||||
|
||||
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
|
||||
- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`).
|
||||
- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Structure (app/)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Livewire\Destination;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -32,17 +31,12 @@ public function mount(string $destination_uuid)
|
|||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
|
||||
SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
|
||||
|
||||
$ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
|
||||
if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
|
||||
$this->destination = $destination;
|
||||
$this->syncData();
|
||||
}
|
||||
});
|
||||
if ($ownedByTeam === false) {
|
||||
return redirect()->route('destination.index');
|
||||
}
|
||||
$this->authorize('view', $destination);
|
||||
|
||||
$this->destination = $destination;
|
||||
$this->syncData();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException) {
|
||||
abort(403);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavbarDeleteTeam extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $team;
|
||||
|
||||
public function mount()
|
||||
|
|
@ -17,27 +21,35 @@ public function mount()
|
|||
|
||||
public function delete($password)
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTeam = currentTeam();
|
||||
$currentTeam->delete();
|
||||
|
||||
$currentTeam->members->each(function ($user) use ($currentTeam) {
|
||||
if ($user->id === Auth::id()) {
|
||||
try {
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
$user->teams()->detach($currentTeam);
|
||||
$session = DB::table('sessions')->where('user_id', $user->id)->first();
|
||||
if ($session) {
|
||||
DB::table('sessions')->where('id', $session->id)->delete();
|
||||
}
|
||||
});
|
||||
|
||||
refreshSession();
|
||||
$currentTeam = currentTeam();
|
||||
$this->authorize('delete', $currentTeam);
|
||||
|
||||
return redirectRoute($this, 'team.index');
|
||||
$currentTeam->members->each(function ($user) use ($currentTeam) {
|
||||
if ($user->id === Auth::id()) {
|
||||
return;
|
||||
}
|
||||
$user->teams()->detach($currentTeam);
|
||||
$session = DB::table('sessions')->where('user_id', $user->id)->first();
|
||||
if ($session) {
|
||||
DB::table('sessions')->where('id', $session->id)->delete();
|
||||
}
|
||||
});
|
||||
|
||||
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
|
||||
$currentTeam->delete();
|
||||
|
||||
$newTeam = Auth::user()->teams()->first();
|
||||
refreshSession($newTeam);
|
||||
|
||||
return redirect()->route('team.index');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@
|
|||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Project $project;
|
||||
|
||||
public string $name;
|
||||
|
|
@ -41,6 +44,7 @@ public function mount(string $project_uuid)
|
|||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Environment::class);
|
||||
$this->validate();
|
||||
$environment = Environment::create([
|
||||
'name' => $this->name,
|
||||
|
|
|
|||
60
tests/Feature/DestinationOwnershipTest.php
Normal file
60
tests/Feature/DestinationOwnershipTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Destination\Show;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
// Team A owns the destination
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA = User::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'admin']);
|
||||
|
||||
$keyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Key A',
|
||||
'private_key' => 'test-key-a',
|
||||
'team_id' => $this->teamA->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->teamA->id,
|
||||
'private_key_id' => $keyId,
|
||||
]);
|
||||
|
||||
// Use the StandaloneDocker created by the Server factory
|
||||
$this->destination = $this->server->standaloneDockers()->first();
|
||||
|
||||
// Team B is a different team
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB = User::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'admin']);
|
||||
});
|
||||
|
||||
test('team member can view their own destination', function () {
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
|
||||
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
|
||||
->assertSet('name', $this->destination->name);
|
||||
});
|
||||
|
||||
test('cross-team user cannot view destination', function () {
|
||||
$this->actingAs($this->userB);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
|
||||
->assertStatus(403);
|
||||
});
|
||||
46
tests/Feature/NavbarDeleteTeamAuthorizationTest.php
Normal file
46
tests/Feature/NavbarDeleteTeamAuthorizationTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\NavbarDeleteTeam;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->personalTeam = $this->owner->teams()->first();
|
||||
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
|
||||
|
||||
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
|
||||
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->member = User::factory()->create();
|
||||
$this->teamToDelete->members()->attach($this->member->id, ['role' => 'member']);
|
||||
});
|
||||
|
||||
test('owner can delete team via navbar', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->teamToDelete]);
|
||||
|
||||
Livewire::test(NavbarDeleteTeam::class)
|
||||
->call('delete', 'password')
|
||||
->assertRedirect(route('team.index'));
|
||||
|
||||
expect(Team::find($this->teamToDelete->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('member cannot delete team via navbar', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->teamToDelete]);
|
||||
|
||||
Livewire::test(NavbarDeleteTeam::class)
|
||||
->call('delete', 'password')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Team::find($this->teamToDelete->id))->not->toBeNull();
|
||||
});
|
||||
55
tests/Feature/ProjectEnvironmentAuthorizationTest.php
Normal file
55
tests/Feature/ProjectEnvironmentAuthorizationTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Show;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 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']);
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin can create environment in project', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
|
||||
->set('name', 'staging')
|
||||
->call('submit')
|
||||
->assertRedirect();
|
||||
|
||||
expect(Environment::where('name', 'staging')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('member cannot create environment in project', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
|
||||
->set('name', 'staging')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Environment::where('name', 'staging')->exists())->toBeFalse();
|
||||
});
|
||||
263
tests/v4/Browser/AuthorizationTest.php
Normal file
263
tests/v4/Browser/AuthorizationTest.php
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
|
||||
// Create root/owner user
|
||||
$this->user = User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
// Create SSH key for the root user's team
|
||||
PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
// Create servers for testing
|
||||
Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => 'localhost',
|
||||
'name' => 'localhost',
|
||||
'description' => 'Test docker container in development',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'uuid' => 'production-1',
|
||||
'name' => 'production-web',
|
||||
'description' => 'Production web server',
|
||||
'ip' => '10.0.0.1',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
// Create projects for testing
|
||||
Project::create([
|
||||
'uuid' => 'project-1',
|
||||
'name' => 'My first project',
|
||||
'description' => 'This is a test project',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-2',
|
||||
'name' => 'Production API',
|
||||
'description' => 'Backend services',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
// Create a member user attached to root team only
|
||||
$this->member = User::factory()->create([
|
||||
'name' => 'Member User',
|
||||
'email' => 'member@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
// Remove auto-created personal team so member only belongs to root team
|
||||
$personalTeam = $this->member->teams()->first();
|
||||
$this->member->teams()->detach($personalTeam->id);
|
||||
$personalTeam->delete();
|
||||
// Attach member to root team (id=0) with 'member' role
|
||||
$this->member->teams()->attach(0, ['role' => 'member']);
|
||||
});
|
||||
|
||||
function loginAndSkipOnboarding(): mixed
|
||||
{
|
||||
return visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->click('Skip Setup');
|
||||
}
|
||||
|
||||
function loginAsMember(): mixed
|
||||
{
|
||||
return visit('/login')
|
||||
->fill('email', 'member@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login');
|
||||
}
|
||||
|
||||
it('redirects unauthenticated users to login', function () {
|
||||
$page = visit('/dashboard');
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows dashboard after successful login and onboarding skip', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Dashboard')
|
||||
->assertSee('Your self-hosted infrastructure')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('displays all projects on dashboard', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Projects')
|
||||
->assertSee('My first project')
|
||||
->assertSee('This is a test project')
|
||||
->assertSee('Production API')
|
||||
->assertSee('Backend services')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('displays all servers on dashboard', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Servers')
|
||||
->assertSee('localhost')
|
||||
->assertSee('Test docker container in development')
|
||||
->assertSee('production-web')
|
||||
->assertSee('Production web server')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access team settings', function () {
|
||||
loginAndSkipOnboarding();
|
||||
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertSee('Manage the general settings of this team')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows danger zone to team owner', function () {
|
||||
loginAndSkipOnboarding();
|
||||
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('Danger Zone')
|
||||
->assertSee('Delete Team')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('prevents unauthenticated access to team settings', function () {
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('prevents unauthenticated access to server show page', function () {
|
||||
$server = Server::first();
|
||||
$page = visit("/server/{$server->uuid}");
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('prevents unauthenticated access to project show page', function () {
|
||||
$project = Project::first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('authenticated user can navigate to server details', function () {
|
||||
loginAndSkipOnboarding();
|
||||
|
||||
// Navigate to server show page using UUID
|
||||
$server = Server::first();
|
||||
$page = visit("/server/{$server->uuid}");
|
||||
|
||||
// Server page should load without redirect
|
||||
$page->assertSee('localhost')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('authenticated user can navigate to project details', function () {
|
||||
loginAndSkipOnboarding();
|
||||
|
||||
// Navigate to project show page using UUID
|
||||
$project = Project::first();
|
||||
$page = visit("/project/{$project->uuid}");
|
||||
|
||||
// Project page should load without redirect
|
||||
$page->assertSee('My first project')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('prevents unauthenticated access to team members page', function () {
|
||||
$page = visit('/team/members');
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('authenticated user can access team members page', function () {
|
||||
loginAndSkipOnboarding();
|
||||
|
||||
$page = visit('/team/members');
|
||||
|
||||
$page->assertSee('Members')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
// --- Negative authorization tests (member role) ---
|
||||
|
||||
it('member does not see add project button on dashboard', function () {
|
||||
$page = loginAsMember();
|
||||
|
||||
$page->assertSee('Projects')
|
||||
->assertDontSee('New Project')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('member does not see add server button on dashboard', function () {
|
||||
$page = loginAsMember();
|
||||
|
||||
$page->assertSee('Servers')
|
||||
->assertDontSee('New Server')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('member does not see danger zone on team settings', function () {
|
||||
loginAsMember();
|
||||
|
||||
$page = visit('/team');
|
||||
|
||||
$page->assertSee('General')
|
||||
->assertDontSee('Danger Zone')
|
||||
->assertDontSee('Delete Team')
|
||||
->screenshot();
|
||||
});
|
||||
Loading…
Reference in a new issue