Fix team authorization, invite revocation, and slug handling
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\TeamMemberRole;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvite;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\User;
|
||||
use Database\Factories\TeamFactory;
|
||||
use Database\Factories\TeamInviteFactory;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('team endpoints require authentication', function (string $method, string $routeName) {
|
||||
$team = TeamFactory::new()->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);
|
||||
});
|
||||
Reference in New Issue
Block a user