create(); $this->{$method}(route($routeName, $team->slug))->assertRedirect(route('login')); })->with([ ['get', 'teams.index'], ['get', 'teams.show'], ['get', 'teams.create'], ['post', 'teams.store'], ['get', 'teams.edit'], ['patch', 'teams.update'], ['post', 'teams.invite.revoke'], ['post', 'teams.nameToSlug'], ]); test('outsiders cannot read a team', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $this->actingAs($user)->get(route('teams.show', $team->slug))->assertForbidden(); }); test('members cannot access the team edit form', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Member]); $this->actingAs($user)->get(route('teams.edit', $team->slug))->assertForbidden(); }); test('members and outsiders cannot modify a team', function (bool $isMember) { $user = User::factory()->create(); $team = TeamFactory::new()->create(); if ($isMember) { $team->members()->attach($user, ['role' => TeamMemberRole::Member]); } $this->actingAs($user)->patch(route('teams.update', $team->slug), [ 'name' => 'Hijacked', 'require_2fa' => true, ])->assertForbidden(); $this->assertDatabaseHas('teams', ['id' => $team->id, 'name' => $team->name, 'require_2fa' => false]); })->with([true, false]); test('unverified users cannot create teams', function (string $method, string $routeName) { $user = User::factory()->unverified()->create(); $this->actingAs($user)->{$method}(route($routeName), [ 'name' => 'Test Team', 'require_2fa' => false, ])->assertForbidden(); $this->assertDatabaseCount('teams', 0); })->with([['get', 'teams.create'], ['post', 'teams.store']]); test('team creation derives the slug and adds its creator as admin', function () { $user = User::factory()->create(); $this->actingAs($user)->post(route('teams.store'), [ 'name' => 'Test Team', 'slug' => 'forged', 'require_2fa' => true, 'id' => 999, ])->assertSessionHasNoErrors()->assertRedirect(route('teams.index')); $this->assertDatabaseHas('teams', ['name' => 'Test Team', 'slug' => 'test-team', 'require_2fa' => true]); $this->assertDatabaseHas('team_members', ['user_id' => $user->id, 'role' => 'admin']); $this->assertDatabaseMissing('teams', ['id' => 999]); }); test('admins can rename teams and follow the updated slug', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $this->actingAs($user)->patch(route('teams.update', $team->slug), [ 'name' => 'New Name', 'slug' => 'forged', 'require_2fa' => true, ])->assertSessionHasNoErrors()->assertRedirect(route('teams.show', 'new-name')); $this->assertDatabaseHas('teams', ['id' => $team->id, 'name' => 'New Name', 'slug' => 'new-name', 'require_2fa' => true]); }); test('updating settings without a name preserves the existing slug', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $this->actingAs($user)->patch(route('teams.update', $team->slug), [ 'require_2fa' => true, 'slug' => 'forged', ])->assertSessionHasNoErrors()->assertRedirect(route('teams.show', $team->slug)); $this->assertDatabaseHas('teams', ['id' => $team->id, 'name' => $team->name, 'slug' => $team->slug, 'require_2fa' => true]); }); test('invalid names are rejected without server errors', function (mixed $name, string $errorKey, string $method, string $routeName) { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $this->actingAs($user)->{$method}(route($routeName, $team->slug), [ 'name' => $name, 'require_2fa' => false, ])->assertUnprocessable()->assertJsonValidationErrors($errorKey); $this->assertDatabaseCount('teams', 1); $this->assertDatabaseHas('teams', ['id' => $team->id, 'name' => $team->name, 'slug' => $team->slug]); })->with([ 'array' => [['bad'], 'name'], 'empty' => ['', 'name'], 'null' => [null, 'name'], 'no slug characters' => ['!!!', 'slug'], 'reserved path' => ['Create', 'slug'], ])->with([['postJson', 'teams.store'], ['patchJson', 'teams.update']]); test('duplicate team slugs are rejected', function (string $method, string $routeName) { $user = User::factory()->create(); $team = TeamFactory::new()->create(); TeamFactory::new()->create(['name' => 'Taken Name', 'slug' => 'taken-name']); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $this->actingAs($user)->{$method}(route($routeName, $team->slug), [ 'name' => 'Taken Name', 'require_2fa' => false, ])->assertUnprocessable()->assertJsonValidationErrors(['slug' => 'The name has already been taken.']); })->with([['postJson', 'teams.store'], ['patchJson', 'teams.update']]); test('team index only lists memberships', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Member]); $this->actingAs($user)->get(route('teams.index'))->assertInertia(fn (Assert $page) => $page ->component('team/Index')->has('teams', 1)->where('teams.0.slug', $team->slug)); }); test('members can view their team without seeing pending invites', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Member]); TeamInviteFactory::new()->createQuietly(['team_id' => $team->id, 'invited_by_user_id' => $user->id]); $this->actingAs($user)->get(route('teams.show', $team->slug))->assertInertia(fn (Assert $page) => $page ->component('team/Show')->where('canManageTeam', false)->missing('teamInvites')); }); test('admins see pending invites without tokens or repeated team data', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $invite = TeamInviteFactory::new()->createQuietly(['team_id' => $team->id, 'invited_by_user_id' => $user->id]); $this->actingAs($user)->get(route('teams.show', $team->slug))->assertInertia(fn (Assert $page) => $page ->component('team/Show')->where('canManageTeam', true)->has('teamInvites', 1) ->where('teamInvites.0.invitedEmail', $invite->invited_email) ->missing('teamInvites.0.invite_token')->missing('teamInvites.0.team') ->missing('team.members.0.password')->missing('team.members.0.two_factor_secret')); }); test('member two factor status requires confirmed two factor authentication', function (bool $enabled, bool $confirmed, bool $expected) { $user = User::factory()->create(); $member = User::factory()->create([ 'two_factor_secret' => $enabled ? encrypt('secret') : null, 'two_factor_confirmed_at' => $confirmed ? now() : null, ]); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $team->members()->attach($member, ['role' => TeamMemberRole::Member]); $this->actingAs($user)->get(route('teams.show', $team->slug))->assertInertia(fn (Assert $page) => $page ->where('team.members.1.has_multi_factor', $expected)); })->with([ 'verified email only' => [false, false, false], 'unconfirmed setup' => [true, false, false], 'confirmed 2fa' => [true, true, true], ]); test('members cannot revoke invitations even with invalid input', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Member]); $invite = TeamInviteFactory::new()->createQuietly(['team_id' => $team->id, 'invited_by_user_id' => $user->id]); $this->actingAs($user)->postJson(route('teams.invite.revoke', $team->slug), [])->assertForbidden(); $this->assertModelExists($invite); }); test('admins cannot revoke invitations of another team', function () { $user = User::factory()->create(); $ownTeam = TeamFactory::new()->create(); $ownTeam->members()->attach($user, ['role' => TeamMemberRole::Admin]); $invite = TeamInviteFactory::new()->createQuietly(); $this->actingAs($user)->postJson(route('teams.invite.revoke', $invite->team->slug), [ 'email' => $invite->invited_email, ])->assertForbidden(); $this->assertModelExists($invite); }); test('revoke does not reveal whether an email is invited to another team', function (bool $hasOtherInvite) { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); if ($hasOtherInvite) { TeamInviteFactory::new()->createQuietly(['invited_email' => 'elsewhere@example.com']); } $this->actingAs($user)->postJson(route('teams.invite.revoke', $team->slug), [ 'email' => 'elsewhere@example.com', ])->assertNotFound(); $this->assertDatabaseCount('team_invites', $hasOtherInvite ? 1 : 0); })->with([true, false]); test('admins revoke only the matching invitation and return to the team', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $invite = TeamInviteFactory::new()->createQuietly(['team_id' => $team->id, 'invited_by_user_id' => $user->id]); $otherInvite = TeamInviteFactory::new()->createQuietly(['invited_email' => $invite->invited_email]); $this->actingAs($user)->post(route('teams.invite.revoke', $team->slug), [ 'email' => $invite->invited_email, ])->assertSessionHasNoErrors()->assertRedirect(route('teams.show', $team->slug)); $this->assertModelMissing($invite); $this->assertModelExists($otherInvite); }); test('slug preview reports availability and allows the unchanged slug', function (string $name, ?string $ignoredSlug, bool $taken) { $user = User::factory()->create(); TeamFactory::new()->create(['name' => 'Existing Team', 'slug' => 'existing-team']); $this->actingAs($user)->postJson(route('teams.nameToSlug'), [ 'name' => $name, 'ignored_slug' => $ignoredSlug, ])->assertOk()->assertJsonPath('taken', $taken); })->with([ ['Existing Team', null, true], ['Existing Team', 'existing-team', false], ['New Team', null, false], ['Create', null, true], ]); test('team creation rolls back when admin membership cannot be saved', function () { $user = User::factory()->create(); $exception = new RuntimeException('Membership persistence failed.'); Exceptions::fake(); Event::listen('eloquent.creating: '.TeamMember::class, function () use ($exception): void { throw $exception; }); $this->actingAs($user)->from(route('teams.create'))->post(route('teams.store'), [ 'name' => 'Test Team', 'require_2fa' => false, ])->assertRedirect(route('teams.create'))->assertSessionHas('_old_input.name', 'Test Team'); $this->assertDatabaseCount('teams', 0); $this->assertDatabaseCount('team_members', 0); $this->assertModelExists($user); Exceptions::assertReported(fn (RuntimeException $reported): bool => $reported === $exception); }); test('failed updates return to the original edit form and report the error', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $exception = new RuntimeException('Team persistence failed.'); Exceptions::fake(); Event::listen('eloquent.updating: '.Team::class, function () use ($exception): void { throw $exception; }); $this->actingAs($user)->from(route('teams.edit', $team->slug))->patch(route('teams.update', $team->slug), [ 'name' => 'New Name', 'require_2fa' => true, ])->assertRedirect(route('teams.edit', $team->slug))->assertSessionHas('_old_input.name', 'New Name'); $this->assertDatabaseHas('teams', ['id' => $team->id, 'name' => $team->name, 'slug' => $team->slug, 'require_2fa' => false]); Exceptions::assertReported(fn (RuntimeException $reported): bool => $reported === $exception); }); test('failed revocations preserve the invitation and report the error', function () { $user = User::factory()->create(); $team = TeamFactory::new()->create(); $team->members()->attach($user, ['role' => TeamMemberRole::Admin]); $invite = TeamInviteFactory::new()->createQuietly(['team_id' => $team->id, 'invited_by_user_id' => $user->id]); $exception = new RuntimeException('Invite deletion failed.'); Exceptions::fake(); Event::listen('eloquent.deleting: '.TeamInvite::class, function () use ($exception): void { throw $exception; }); $this->actingAs($user)->post(route('teams.invite.revoke', $team->slug), [ 'email' => $invite->invited_email, ])->assertRedirect(route('teams.show', $team->slug)); $this->assertModelExists($invite); Exceptions::assertReported(fn (RuntimeException $reported): bool => $reported === $exception); });