Fix team authorization, invite revocation, and slug handling
CI/CD / test_and_check_app (push) Failing after 58s
CI/CD / Publish immutable image (push) Skipped

This commit is contained in:
2026-09-19 05:54:18 +02:00
parent 3db250ca51
commit e9fc8cabb5
18 changed files with 688 additions and 226 deletions
+306
View File
@@ -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);
});
+29
View File
@@ -0,0 +1,29 @@
<?php
use App\Enums\TeamMemberRole;
use App\Models\Team;
use App\Models\User;
use Database\Factories\TeamFactory;
test('team permissions follow membership and role', function (?TeamMemberRole $role, bool $canView, bool $canManage) {
$user = User::factory()->create();
$team = TeamFactory::new()->create();
if ($role !== null) {
$team->members()->attach($user, ['role' => $role]);
}
expect($user->can('view', $team))->toBe($canView);
expect($user->can('manage', $team))->toBe($canManage);
})->with([
'outsider' => [null, false, false],
'member' => [TeamMemberRole::Member, true, false],
'admin' => [TeamMemberRole::Admin, true, true],
]);
test('team creation requires a verified email', function () {
$verified = User::factory()->create();
$unverified = User::factory()->unverified()->create();
expect($verified->can('create', Team::class))->toBeTrue();
expect($unverified->can('create', Team::class))->toBeFalse();
});