You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/tests/Feature/Federation/QuotePostsTest.php

818 lines
29 KiB
PHP

<?php
use App\Jobs\QuotePipeline\DeliverQuoteActivityPipeline;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\Status;
use App\Models\User;
use App\Models\UserFilter;
use App\Services\QuoteService;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\Note;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\Inbox;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Laravel\Passport\Passport;
use League\Fractal;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| FEP-044f: Consent-respecting quote posts (target side)
|--------------------------------------------------------------------------
*/
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
config([
'instance.enable_cc' => false,
'federation.activitypub.enabled' => true,
]);
});
function quoteLocalUser(string $canQuote = 'everyone'): User
{
$user = User::factory()->create();
$user->refresh();
if ($canQuote !== 'everyone') {
$settings = $user->settings;
$settings->can_quote = $canQuote;
$settings->save();
}
return $user;
}
function quoteLocalStatus(Profile $profile, array $attributes = []): Status
{
return Status::factory()->photo()->create(array_merge([
'profile_id' => $profile->id,
], $attributes));
}
function quoteRemoteProfile(string $domain = 'remote.example', string $username = 'bob', array $attributes = []): Profile
{
$actor = "https://{$domain}/users/{$username}";
return Profile::factory()->remote()->create(array_merge([
'domain' => $domain,
'username' => "@{$username}@{$domain}",
'remote_url' => $actor,
'key_id' => "{$actor}#main-key",
'inbox_url' => "{$actor}/inbox",
'sharedInbox' => "https://{$domain}/inbox",
'last_fetched_at' => now(),
], $attributes));
}
/**
* Seed the DNS and banned-domain caches so URL validation passes without a
* network lookup. Call after factories, the lazy refresh can flush the cache.
*/
function quoteSeedHosts(array $hosts = ['remote.example', 'other.example']): void
{
$hosts[] = config('pixelfed.domain.app');
foreach ($hosts as $host) {
Cache::put(
'helpers:url:public-ips:v2:'.hash('xxh128', $host),
['state' => Helpers::URL_OK, 'ips' => ['203.0.113.40']],
3600
);
}
Cache::put('instances:banned:domains', [], 1209600);
}
function quoteInProduction(callable $fn): mixed
{
$app = app();
$previous = $app['env'];
$app['env'] = 'production';
try {
return $fn();
} finally {
$app['env'] = $previous;
}
}
function quoteRequestPayload(Profile $actor, Status $status, array $overrides = []): array
{
$quoteUrl = $actor->remote_url.'/statuses/1';
return array_merge([
'@context' => [
'https://www.w3.org/ns/activitystreams',
['QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest'],
],
'id' => $quoteUrl.'/quote',
'type' => 'QuoteRequest',
'actor' => $actor->remote_url,
'object' => $status->url(),
'instrument' => $quoteUrl,
], $overrides);
}
function quoteDeliver(Profile $actor, array $payload, ?Profile $signer = null): void
{
$keyId = ($signer ?? $actor)->key_id;
$headers = [
'signature' => ['keyId="'.$keyId.'",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="dGVzdA=="'],
'date' => [now()->toRfc7231String()],
];
quoteInProduction(fn () => (new Inbox($headers, null, $payload))->handle());
}
/**
* Activities handed to the delivery job, in order. Accept, Reject and Delete
* are all delivered from DeliverQuoteActivityPipeline.
*/
function quoteSentActivities(): array
{
return Queue::pushed(DeliverQuoteActivityPipeline::class)
->map(fn (DeliverQuoteActivityPipeline $job) => $job->activity())
->values()
->all();
}
function quoteSentOfType(string $type): array
{
return array_values(array_filter(quoteSentActivities(), fn ($activity) => $activity['type'] === $type));
}
function quoteNoteObject(Status $status): array
{
$fractal = new Fractal\Manager;
return $fractal->createData(new Fractal\Resource\Item($status, new Note))->toArray()['data'];
}
describe('advertised policy', function () {
it('advertises public automatic approval by default', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$note = quoteNoteObject($status);
expect($note['interactionPolicy']['canQuote']['automaticApproval'])
->toBe(['https://www.w3.org/ns/activitystreams#Public']);
$terms = $note['@context'][2];
expect($terms['canQuote']['@id'])->toBe('gts:canQuote')
->and($terms['interactionPolicy']['@id'])->toBe('gts:interactionPolicy')
->and($terms['gts'])->toBe('https://gotosocial.org/ns#');
});
it('advertises the followers collection for the followers policy', function () {
$user = quoteLocalUser('followers');
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink('/followers')]);
});
it('advertises the author alone for the nobody policy', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink()]);
});
it('lets a per-post override win over the account default', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAG_PUBLIC]);
expect(QuoteService::statusPolicy($status))->toBe('everyone');
});
it('never lets followers-only posts be quoted, whatever the override says', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, [
'scope' => 'private',
'visibility' => 'private',
'quote_policy' => QuoteService::FLAG_PUBLIC,
]);
expect(QuoteService::statusPolicy($status))->toBe('nobody')
->and(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink()]);
});
it('keeps an override of nobody apart from having no override', function () {
$user = quoteLocalUser();
$inherits = quoteLocalStatus($user->profile);
$nobody = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAGS_NOBODY]);
expect($inherits->fresh()->quote_policy)->toBeNull()
->and($nobody->fresh()->quote_policy)->toBe(0)
->and(QuoteService::statusPolicy($inherits->fresh()))->toBe('everyone')
->and(QuoteService::statusPolicy($nobody->fresh()))->toBe('nobody')
->and(QuoteService::fromApiPolicy('nobody'))->toBe(0)
->and(QuoteService::fromApiPolicy(null))->toBeNull()
->and(QuoteService::fromApiPolicy('bogus'))->toBeNull();
});
it('advertises every audience in a combined bitmask', function () {
$user = quoteLocalUser('nobody');
$profile = $user->profile;
$status = quoteLocalStatus($profile, [
'quote_policy' => QuoteService::FLAG_FOLLOWERS
| QuoteService::FLAG_FOLLOWING
| (QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT),
]);
$policy = quoteNoteObject($status)['interactionPolicy']['canQuote'];
expect($policy['automaticApproval'])->toBe([
$profile->permalink('/followers'),
$profile->permalink('/following'),
])->and($policy['manualApproval'])->toBe([
'https://www.w3.org/ns/activitystreams#Public',
])->and(QuoteService::statusPolicy($status))->toBe('followers');
});
it('leaves manualApproval out when nothing needs review', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote'])
->not->toHaveKey('manualApproval');
});
it('puts the policy on the object of a Create', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$fractal = new Fractal\Manager;
$create = $fractal->createData(new Fractal\Resource\Item($status, new CreateNote))->toArray()['data'];
expect($create['object']['interactionPolicy']['canQuote']['automaticApproval'])
->toBe(['https://www.w3.org/ns/activitystreams#Public'])
->and($create['@context'][2])->toHaveKey('canQuote');
});
});
describe('QuoteRequest', function () {
it('accepts with a stamp when the policy allows it', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$payload = quoteRequestPayload($bob, $status);
quoteDeliver($bob, $payload);
$auth = QuoteAuthorization::first();
expect($auth)->not->toBeNull()
->and($auth->isApproved())->toBeTrue()
->and((int) $auth->status_id)->toBe((int) $status->id)
->and((int) $auth->actor_id)->toBe((int) $bob->id)
->and($auth->quote_url)->toBe($payload['instrument']);
$sent = quoteSentActivities();
expect($sent)->toHaveCount(1);
$accept = $sent[0];
expect($accept['type'])->toBe('Accept')
->and($accept['actor'])->toBe($user->profile->permalink())
->and($accept['to'])->toBe($bob->remote_url)
->and($accept['result'])->toBe($auth->permalink())
->and($accept['object']['type'])->toBe('QuoteRequest')
->and($accept['object']['id'])->toBe($payload['id'])
->and($accept['object']['actor'])->toBe($bob->remote_url)
->and($accept['object']['object'])->toBe($status->url())
->and($accept['object']['instrument'])->toBe($payload['instrument']);
$job = Queue::pushed(DeliverQuoteActivityPipeline::class)->first();
quoteInProduction(fn () => $job->handle());
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://remote.example/inbox'
&& json_decode($request->body(), true)['type'] === 'Accept');
});
it('rejects when the account policy is nobody', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
$sent = quoteSentActivities();
expect(QuoteAuthorization::count())->toBe(0)
->and($sent)->toHaveCount(1)
->and($sent[0]['type'])->toBe('Reject')
->and($sent[0]['object']['type'])->toBe('QuoteRequest')
->and($sent[0])->not->toHaveKey('result');
});
it('honours a per-post nobody override on an otherwise open account', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAGS_NOBODY]);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('only accepts followers under the followers policy', function () {
$user = quoteLocalUser('followers');
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
DB::table('followers')->insert([
'profile_id' => $carol->id,
'following_id' => $user->profile->id,
'created_at' => now(),
'updated_at' => now(),
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($carol, quoteRequestPayload($carol, $status));
$sent = quoteSentActivities();
expect($sent[0]['type'])->toBe('Reject')
->and($sent[1]['type'])->toBe('Accept')
->and(QuoteAuthorization::count())->toBe(1)
->and((int) QuoteAuthorization::first()->actor_id)->toBe((int) $carol->id);
});
it('accepts accounts the author follows under a following bitmask', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAG_FOLLOWING]);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
DB::table('followers')->insert([
'profile_id' => $user->profile->id,
'following_id' => $carol->id,
'created_at' => now(),
'updated_at' => now(),
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($carol, quoteRequestPayload($carol, $status));
$sent = quoteSentActivities();
expect($sent[0]['type'])->toBe('Reject')
->and($sent[1]['type'])->toBe('Accept');
});
it('does not act on audiences that only have manual approval', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, [
'quote_policy' => QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT,
]);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('rejects an actor the author has blocked', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
UserFilter::create([
'user_id' => $user->profile->id,
'filterable_id' => $bob->id,
'filterable_type' => Profile::class,
'filter_type' => 'block',
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('ignores requests for followers-only posts without answering', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, ['scope' => 'private', 'visibility' => 'private']);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('ignores a quote post hosted somewhere other than the requester', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => 'https://other.example/users/mallory/statuses/9',
]));
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('ignores a request signed by a different actor than it claims', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$mallory = quoteRemoteProfile('remote.example', 'mallory');
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status), $mallory);
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('accepts an inlined quote post whose id is not its first property', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$quoteUrl = $bob->remote_url.'/statuses/1';
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $quoteUrl,
'attributedTo' => $bob->remote_url,
'content' => 'look at this',
'quote' => $status->url(),
'quoteUrl' => $status->url(),
'_misskey_quote' => $status->url(),
],
]));
expect(QuoteAuthorization::first()?->quote_url)->toBe($quoteUrl)
->and(quoteSentActivities()[0]['type'])->toBe('Accept');
});
it('rejects an inlined quote post written by someone else', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $bob->remote_url.'/statuses/1',
'attributedTo' => 'https://remote.example/users/carol',
'quote' => $status->url(),
],
]));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('rejects an inlined quote post that quotes a different post', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$other = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $bob->remote_url.'/statuses/1',
'attributedTo' => $bob->remote_url,
'quote' => $other->url(),
],
]));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('re-sends the same stamp for a repeated request', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($bob, quoteRequestPayload($bob, $status, ['id' => $bob->remote_url.'/statuses/1/quote-again']));
$sent = quoteSentActivities();
expect(QuoteAuthorization::count())->toBe(1)
->and($sent)->toHaveCount(2)
->and($sent[1]['type'])->toBe('Accept')
->and($sent[1]['result'])->toBe($sent[0]['result']);
});
it('keeps rejecting a quote whose stamp was revoked', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
QuoteService::revoke(QuoteAuthorization::first());
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(array_column(quoteSentActivities(), 'type'))->toBe(['Accept', 'Delete', 'Reject'])
->and(QuoteAuthorization::approved()->count())->toBe(0);
});
});
describe('delivery', function () {
function quoteDeliveryJob(): DeliverQuoteActivityPipeline
{
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
return (new DeliverQuoteActivityPipeline(
$user->profile->id,
$bob->id,
QuoteService::acceptActivity($auth, $bob->remote_url.'/statuses/1/quote')
))->withFakeQueueInteractions();
}
it('is done after a 2xx', function () {
$job = quoteDeliveryJob();
Http::swap(new Factory);
Http::fake(['*' => Http::response('', 202)]);
quoteInProduction(fn () => $job->handle());
$job->assertNotReleased();
Http::assertSentCount(1);
});
it('tries again later when the remote is struggling', function (mixed $failure) {
$job = quoteDeliveryJob();
Http::swap(new Factory);
Http::fake(['*' => function () use ($failure) {
if ($failure === 'connection') {
throw new ConnectionException('timed out');
}
return Http::response('', $failure);
}]);
quoteInProduction(fn () => $job->handle());
$job->assertReleased(delay: 30);
})->with([
'500' => [500],
'503' => [503],
'429' => [429],
'connection error' => ['connection'],
]);
it('gives up when the remote refuses', function (int $status) {
$job = quoteDeliveryJob();
Http::swap(new Factory);
Http::fake(['*' => Http::response('', $status)]);
quoteInProduction(fn () => $job->handle());
$job->assertNotReleased();
})->with([400, 401, 403, 404, 410]);
it('has one more try than it has delays', function () {
expect(quoteDeliveryJob()->tries)->toBe(count(DeliverQuoteActivityPipeline::RETRY_DELAYS) + 1);
});
});
describe('stamp', function () {
it('serves a QuoteAuthorization that satisfies the FEP verification rules', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$quoteUrl = $bob->remote_url.'/statuses/1';
$auth = QuoteService::authorize($status, $bob, $quoteUrl);
$this->get("/users/{$user->profile->username}/quote_authorizations/{$auth->id}")
->assertOk()
->assertHeader('Content-Type', 'application/activity+json')
->assertJson([
'id' => $auth->permalink(),
'type' => 'QuoteAuthorization',
'attributedTo' => $user->profile->permalink(),
'interactingObject' => $quoteUrl,
'interactionTarget' => $status->url(),
]);
expect(parse_url($auth->permalink(), PHP_URL_HOST))
->toBe(parse_url($user->profile->permalink(), PHP_URL_HOST));
});
it('is gone once revoked and unknown under another username', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->get("/users/{$stranger->profile->username}/quote_authorizations/{$auth->id}")
->assertNotFound();
QuoteService::revoke($auth);
$this->get("/users/{$user->profile->username}/quote_authorizations/{$auth->id}")
->assertStatus(410);
});
});
describe('revocation', function () {
it('queues a Delete that references the stamp without inlining either post', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::revoke($auth);
expect(quoteSentOfType('Delete'))->toHaveCount(1);
$delete = quoteSentOfType('Delete')[0];
expect($delete['type'])->toBe('Delete')
->and($delete['actor'])->toBe($user->profile->permalink())
->and($delete['object']['id'])->toBe($auth->permalink())
->and($delete['object']['type'])->toBe('QuoteAuthorization')
->and($delete['object']['interactingObject'])->toBeString()
->and($delete['object']['interactionTarget'])->toBeString();
});
it('revokes every stamp issued to an actor when the author blocks them', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/2');
QuoteService::authorize($status, $carol, $carol->remote_url.'/statuses/1');
QuoteService::revokeForActor($user->profile->id, $bob->id);
expect(QuoteAuthorization::approved()->count())->toBe(1)
->and((int) QuoteAuthorization::approved()->first()->actor_id)->toBe((int) $carol->id);
expect(quoteSentOfType('Delete'))->toHaveCount(2);
});
it('revokes every stamp issued to a domain when the author blocks it', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$dave = quoteRemoteProfile('other.example', 'dave');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::authorize($status, $dave, $dave->remote_url.'/statuses/1');
QuoteService::revokeForDomain($user->profile->id, 'REMOTE.example');
expect(QuoteAuthorization::approved()->count())->toBe(1)
->and((int) QuoteAuthorization::approved()->first()->actor_id)->toBe((int) $dave->id);
});
});
describe('settings', function () {
it('saves the account default from the privacy page', function () {
$user = quoteLocalUser();
$this->actingAs($user)
->get('/settings/privacy')
->assertOk()
->assertSee('Who can quote your posts');
// A fresh model, the GET above decorates the cached settings
// relation with view-only attributes that must not be saved.
$this->actingAs($user->fresh())
->post('/settings/privacy', ['can_quote' => 'followers'])
->assertRedirect();
expect($user->settings()->first()->can_quote)->toBe('followers')
->and(QuoteService::accountPolicy($user->profile))->toBe('followers');
});
it('lists approved quotes and revokes one', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->actingAs($user)
->get('/settings/privacy/quotes')
->assertOk()
->assertSee($bob->remote_url.'/statuses/1');
$this->actingAs($user)
->post('/settings/privacy/quotes', ['id' => $auth->id])
->assertRedirect();
expect($auth->fresh()->isRevoked())->toBeTrue();
expect(quoteSentOfType('Delete'))->toHaveCount(1);
});
it('does not let someone revoke a stamp that is not theirs', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->actingAs($stranger)
->post('/settings/privacy/quotes', ['id' => $auth->id])
->assertNotFound();
expect($auth->fresh()->isApproved())->toBeTrue();
});
});
describe('api', function () {
it('sets and clears the per-post override through interaction_policy', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
Passport::actingAs($user, ['read', 'write']);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => 'nobody',
])->assertOk()->assertJsonPath('quote_approval_policy', 'nobody');
expect($status->fresh()->quote_policy)->toBe(QuoteService::FLAGS_NOBODY);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => null,
])->assertOk()->assertJsonPath('quote_approval_policy', 'public');
expect($status->fresh()->quote_policy)->toBeNull();
});
it('does not let someone else change the policy of a post', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
Passport::actingAs($stranger, ['read', 'write']);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => 'nobody',
])->assertNotFound();
expect($status->fresh()->quote_policy)->toBeNull();
});
});