Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions tests/Feature/Controllers/Api/CallControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

use App\Actions\CreateConversationCall;
use App\Actions\CreateOrgCall;
use App\Models\Conversation;
use App\Models\User;

it('shows the incoming caller and ongoing org calls with public member profiles', function (): void {
config(['remotes.requests' => true, 'remotes.everyone' => true]);
$user = User::factory()->withRemotes()->create();
$caller = User::factory()->withRemotes()->create();
$key = apiKeyPairFor($user);

signedApiRequest($key, 'GET', route('api-call.show'))->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertExactJson(['call' => null, 'org_calls' => []]);
sendChatMessage($caller, $user);
resolve(CreateConversationCall::class)->handle($caller, $user, 'v=0 incoming');
[$member] = followersOf($user, 1);
$org = Conversation::query()->findOrFail(createOrg($user, [$member]));
resolve(CreateOrgCall::class)->handle($member, $org);

signedApiRequest($key, 'GET', route('api-call.show'))->assertOk()
->assertJsonPath('call.partner.id', $caller->id)
->assertJsonPath('call.sdp', 'v=0 incoming')
->assertJsonPath('org_calls.0.members.0.user.id', $member->id)
->assertJsonPath('org_calls.0.members.0.joined_at', now()->getTimestampMs())
->assertJsonMissingPath('call.partner.email')
->assertJsonMissingPath('org_calls.0.members.0.user.email');
});
29 changes: 29 additions & 0 deletions tests/Feature/Controllers/Api/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Enums\CommitStatus;
use App\Enums\PollDuration;
use App\Http\Requests\CreateApiCommitRequest;
use App\Http\Requests\CreateCommitReplyRequest;
use App\Jobs\ModerateCommit;
use App\Models\Commit;
use App\Models\CommitImage;
Expand All @@ -18,6 +19,33 @@
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;

it('bounds combined inherited and explicit reply recipients', function (int $inheritedCount, bool $accepted): void {
Queue::fake();
$author = User::factory()->create();
$users = User::factory()->count($inheritedCount)->create();
$mentions = $users->mapWithKeys(fn (User $user): array => [$user->handle => $user->id])->all();
$participant = User::factory()->create(['handle' => 'new-participant']);
$parent = Commit::factory()->create(['mentions' => $mentions]);

$response = signedApiRequest(apiKeyPairFor($author), 'POST', route('api-commit.store'), [
'parent_id' => $parent->id,
'body' => 'Hello @new-participant @'.$users->firstOrFail()->handle,
'reply_recipients' => array_values($mentions),
]);

if ($accepted) {
$response->assertCreated();
expect($parent->replies()->sole()->mentions)->toHaveCount(CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS);
} else {
$response->assertUnprocessable()->assertJsonValidationErrors('reply_recipients');
expect($parent->replies()->count())->toBe(0)
->and($participant->notifications()->count())->toBe(0);
}
})->with([
'at limit with overlapping explicit mention' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS - 1, true],
'combined recipients over limit' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS, false],
]);

it('shows an approved commit to anyone', function (): void {
$user = User::factory()->create([
'handle' => 'octocat',
Expand Down Expand Up @@ -290,6 +318,7 @@
'quoted_commit' => null,
'poll' => null,
]],
'reply_participants' => [],
]);
});

Expand Down
96 changes: 96 additions & 0 deletions tests/Feature/Controllers/Api/ConversationCallControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

use App\Actions\CreateConversationCall;
use App\Actions\CreateOrgCall;
use App\Enums\OrgEvent;
use App\Models\Message;
use App\Models\User;
use App\Queries\CallQuery;
use Illuminate\Support\Facades\Cache;

beforeEach(function (): void {
config(['remotes.requests' => true, 'remotes.everyone' => true]);
});

it('rings, answers and ends a call through signed requests', function (): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
$callerKey = apiKeyPairFor($caller);
$partnerKey = apiKeyPairFor($partner);
sendChatMessage($caller, $partner);

signedApiRequest($callerKey, 'POST', route('api-conversation-call.store', $partner), ['sdp' => "v=0\r\no=caller\r\n"])->assertNoContent();

expect(resolve(CallQuery::class)->get($partner)->offer)->toBe("v=0\r\no=caller\r\n");
signedApiRequest($callerKey, 'GET', route('api-conversation-call.show', $partner))
->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertExactJson(['call' => ['answer' => null]]);
signedApiRequest($partnerKey, 'PUT', route('api-conversation-call.update', $caller), ['sdp' => 'v=0 answer'])->assertNoContent();
signedApiRequest($callerKey, 'GET', route('api-conversation-call.show', $partner))->assertExactJson(['call' => ['answer' => 'v=0 answer']]);
signedApiRequest($callerKey, 'DELETE', route('api-conversation-call.destroy', $partner))->assertNoContent();

expect(resolve(CallQuery::class)->get($partner))->toBeNull()
->and(Message::query()->whereNotNull('event')->orderBy('id')->pluck('event')->all())
->toBe([OrgEvent::CallStarted, OrgEvent::CallAnswered]);
});

it('records a declined or failed incoming call', function (bool $failed, OrgEvent $event): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
sendChatMessage($caller, $partner);
resolve(CreateConversationCall::class)->handle($caller, $partner, 'v=0');

signedApiRequest(apiKeyPairFor($partner), 'DELETE', route('api-conversation-call.destroy', $caller), ['failed' => $failed])->assertNoContent();

expect(resolve(CallQuery::class)->get($partner))->toBeNull();
$this->assertDatabaseHas('messages', ['event' => $event->value, 'user_id' => $partner->id]);
})->with([[false, OrgEvent::CallDeclined], [true, OrgEvent::CallFailed]]);

it('hides every call operation across a block', function (): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
$partner->blocking()->attach($caller);
$key = apiKeyPairFor($caller);

signedApiRequest($key, 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0'])->assertNotFound();
signedApiRequest($key, 'PUT', route('api-conversation-call.update', $partner), ['sdp' => 'v=0'])->assertNotFound();
signedApiRequest($key, 'GET', route('api-conversation-call.show', $partner))->assertNotFound();
signedApiRequest($key, 'DELETE', route('api-conversation-call.destroy', $partner))->assertNotFound();
});

it('refuses a call before the conversation starts and an answer after it ends', function (): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
$key = apiKeyPairFor($caller);

signedApiRequest($key, 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0'])
->assertForbidden()->assertJsonPath('message', "Say hi to {$partner->mention()} before calling them.");
signedApiRequest($key, 'PUT', route('api-conversation-call.update', $partner), ['sdp' => 'v=0'])
->assertForbidden()->assertJsonPath('message', 'This call has ended.');
expect(resolve(CallQuery::class)->get($partner))->toBeNull();
});

it('refuses calling when either person is in an org call', function (bool $callerIsInOne): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
sendChatMessage($caller, $partner);
Cache::put(CreateOrgCall::memberKey(($callerIsInOne ? $caller : $partner)->id), 'org', CreateOrgCall::SECONDS);

signedApiRequest(apiKeyPairFor($caller), 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0'])
->assertForbidden()->assertJsonPath('message', $callerIsInOne ? "You're already on a call." : "You can't call {$partner->mention()} right now.");
})->with([true, false]);

it('rejects an invalid offer and answer without changing the call', function (): void {
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
sendChatMessage($caller, $partner);

signedApiRequest(apiKeyPairFor($caller), 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'invalid'])
->assertUnprocessable()->assertJsonValidationErrors('sdp');
expect(resolve(CallQuery::class)->get($partner))->toBeNull();
resolve(CreateConversationCall::class)->handle($caller, $partner, 'v=0');
signedApiRequest(apiKeyPairFor($partner), 'PUT', route('api-conversation-call.update', $caller), ['sdp' => 'invalid'])
->assertUnprocessable()->assertJsonValidationErrors('sdp');
expect(resolve(CallQuery::class)->get($partner)->answer)->toBeNull();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

use App\Models\User;

it('only gives relay settings to someone in a messageable conversation', function (): void {
config(['remotes.requests' => true, 'remotes.everyone' => true]);
$caller = User::factory()->withRemotes()->create();
$partner = User::factory()->withRemotes()->create();
$key = apiKeyPairFor($caller);
$url = route('api-conversation-call-ice-server.index', $partner);

signedApiRequest($key, 'GET', $url)->assertNotFound();
sendChatMessage($caller, $partner);
signedApiRequest($key, 'GET', $url)->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertExactJson(['ice_servers' => [], 'is_relay_only' => false]);
$partner->blocking()->attach($caller);
signedApiRequest($key, 'GET', $url)->assertNotFound();
});
18 changes: 18 additions & 0 deletions tests/Feature/Controllers/Api/LiveFollowingControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

use App\Enums\LivePlatform;
use App\Models\User;

it('lists followed people who are live without exposing their private fields', function (): void {
$user = User::factory()->create();
$live = User::factory()->create(['live_platform' => LivePlatform::Twitch, 'live_url' => 'https://www.twitch.tv/octocat', 'live_ends_at' => now()->addHour()]);
$expired = User::factory()->create(['live_platform' => LivePlatform::Twitch, 'live_url' => 'https://www.twitch.tv/expired', 'live_ends_at' => now()->subMinute()]);
$user->following()->attach([$live->id, $expired->id]);

signedApiRequest(apiKeyPairFor($user), 'GET', route('api-live-following.index'))->assertOk()
->assertJsonPath('data.*.id', [$live->id])
->assertJsonPath('data.0.live.url', 'https://www.twitch.tv/octocat')
->assertJsonMissingPath('data.0.email');
});
68 changes: 68 additions & 0 deletions tests/Feature/Controllers/Api/OrgCallControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

use App\Actions\CreateOrgCall;
use App\Models\Conversation;
use App\Models\User;
use App\Queries\OrgCallQuery;

it('starts, joins, exchanges signals and leaves an org call through signed requests', function (): void {
$user = User::factory()->withRemotes()->create();
[$member] = followersOf($user, 1);
$org = Conversation::query()->findOrFail(createOrg($user, [$member]));
$key = apiKeyPairFor($user);
$memberKey = apiKeyPairFor($member);
$signal = ['id' => 'offer-1', 'type' => 'offer', 'sdp' => "v=0\r\no=caller\r\n"];

signedApiRequest($key, 'POST', route('api-org-call.store', $org))->assertCreated()
->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.members.0.user.id', $user->id);
signedApiRequest($memberKey, 'POST', route('api-org-call.store', $org))->assertCreated()->assertJsonCount(2, 'call.members');

$response = signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => $signal]]);

$response->assertOk()->assertJsonPath('call.signals', []);
expect(json_decode($response->getContent(), flags: JSON_THROW_ON_ERROR)->call->signals)->toBeInstanceOf(stdClass::class);
signedApiRequest($memberKey, 'PUT', route('api-org-call.update', $org), ['signals' => []])
->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.signals', [$user->id => $signal]);
signedApiRequest($key, 'DELETE', route('api-org-call.destroy', $org))->assertNoContent();
expect(resolve(OrgCallQuery::class)->isInOne($user))->toBeFalse();
});

it('hides call operations from nonmembers and members with Remotes off', function (bool $isMember): void {
$owner = User::factory()->withRemotes()->create();
[$member] = followersOf($owner, 1);
$org = Conversation::query()->findOrFail(createOrg($owner, [$member]));
$user = $isMember ? $member : User::factory()->withRemotes()->create();
$user->update(['remotes_enabled' => ! $isMember]);

$key = apiKeyPairFor($user);

signedApiRequest($key, 'POST', route('api-org-call.store', $org))->assertNotFound();
signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => []])->assertNotFound();
signedApiRequest($key, 'DELETE', route('api-org-call.destroy', $org))->assertNotFound();
})->with([true, false]);

it('refuses joining another org call and checking in after leaving', function (): void {
$user = User::factory()->withRemotes()->create();
[$member] = followersOf($user, 1);
$first = Conversation::query()->findOrFail(createOrg($user, [$member], 'first'));
$second = Conversation::query()->findOrFail(createOrg($user, [$member], 'second'));
$key = apiKeyPairFor($user);
resolve(CreateOrgCall::class)->handle($user, $first);

signedApiRequest($key, 'POST', route('api-org-call.store', $second))->assertForbidden()
->assertJsonPath('message', "You're already on a call.");
signedApiRequest($key, 'PUT', route('api-org-call.update', $second), ['signals' => []])->assertForbidden()
->assertJsonPath('message', "You're no longer in this call.");
});

it('rejects invalid signals without delivering them', function (): void {
$user = User::factory()->withRemotes()->create();
[$member] = followersOf($user, 1);
$org = Conversation::query()->findOrFail(createOrg($user, [$member]));
resolve(CreateOrgCall::class)->handle($user, $org);

signedApiRequest(apiKeyPairFor($user), 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => ['id' => 'bad', 'type' => 'offer', 'sdp' => 'invalid']]])
->assertUnprocessable()->assertJsonValidationErrors('signals.'.$member->id.'.sdp');
});
20 changes: 20 additions & 0 deletions tests/Feature/Controllers/Api/OrgCallIceServerControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

use App\Models\Conversation;
use App\Models\User;

it('gives relay settings only to org members with Remotes enabled', function (): void {
$user = User::factory()->withRemotes()->create();
[$member] = followersOf($user, 1);
$org = Conversation::query()->findOrFail(createOrg($user, [$member]));
$url = route('api-org-call-ice-server.index', $org);
$key = apiKeyPairFor($user);

signedApiRequest($key, 'GET', $url)->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertExactJson(['ice_servers' => [], 'is_relay_only' => false]);
signedApiRequest(apiKeyPairFor(User::factory()->withRemotes()->create()), 'GET', $url)->assertNotFound();
$user->update(['remotes_enabled' => false]);
signedApiRequest($key, 'GET', $url)->assertNotFound();
});
49 changes: 49 additions & 0 deletions tests/Feature/Controllers/Api/OrganizationAdminControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

use App\Contracts\Services\GitHubProvider;
use App\Models\Organization;
use App\Models\User;

it('lets a public GitHub member claim an organization', function (): void {
$user = User::factory()->create();
$organization = Organization::factory()->create();
$this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once()
->with($organization->profile->github_id, $user->github_id)->andReturnTrue();

signedApiRequest(apiKeyPairFor($user), 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertNoContent();

expect($organization->admins()->pluck('users.id')->all())->toBe([$user->id]);
});

it('refuses unknown, already claimed and nonpublic organization memberships', function (): void {
$user = User::factory()->create();
$key = apiKeyPairFor($user);
$organization = Organization::factory()->create();
$this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once()->andReturnFalse();

signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => 'missing']))->assertNotFound();
signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertForbidden()
->assertJsonPath('message', "GitHub does not list you as a public member of @{$organization->handle}. On GitHub, make your membership of @{$organization->handle} public, then try again.");
$admin = User::factory()->create();
$organization->admins()->attach($admin);
signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertForbidden()
->assertJsonPath('message', "@{$organization->handle} was already claimed by @{$admin->handle}.");
expect($organization->admins()->pluck('users.id')->all())->toBe([$admin->id]);
});

it('returns a conflict if someone else claims the organization during the GitHub check', function (): void {
$user = User::factory()->create();
$other = User::factory()->create();
$organization = Organization::factory()->create();
$this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once()->andReturnUsing(function () use ($organization, $other): bool {
$organization->admins()->attach($other);

return true;
});

signedApiRequest(apiKeyPairFor($user), 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))
->assertConflict()->assertExactJson(['message' => 'Unable to claim this organization. Please try again.']);
expect($organization->admins()->pluck('users.id')->all())->toBe([$other->id]);
});
28 changes: 18 additions & 10 deletions tests/Feature/Controllers/Api/OrganizationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@

$this->getJson(route('api-organization.show', ['handle' => 'laravel']))
->assertOk()
->assertExactJson(['data' => [
'handle' => 'laravel',
'name' => 'Laravel',
'avatar_url' => 'https://avatars.githubusercontent.com/u/958072',
'bio' => 'The PHP framework for web artisans.',
'location' => 'United States of America',
'website' => 'https://laravel.com',
'github_url' => 'https://github.com/laravel',
'html_url' => route('user.show', ['user' => 'laravel']),
]]);
->assertExactJson([
'data' => [
'handle' => 'laravel',
'name' => 'Laravel',
'avatar_url' => 'https://avatars.githubusercontent.com/u/958072',
'bio' => 'The PHP framework for web artisans.',
'location' => 'United States of America',
'website' => 'https://laravel.com',
'github_url' => 'https://github.com/laravel',
'html_url' => route('user.show', ['user' => 'laravel']),
'lane' => null,
'followers_count' => 0,
'admins' => [],
],
'is_following' => false,
'can_claim' => false,
'settings_url' => null,
]);
});

it('returns not found for handles without an organization page', function (): void {
Expand Down
Loading
Loading