test: add quote-posts coverage for policy resolution, canQuote guards, validation, revocation guards, and sameUrl

configcache-clean-rebased
Your Name 4 days ago
parent a5547349d7
commit ed06f72e47

@ -10,6 +10,7 @@ use App\Services\QuoteService;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\Note;
use App\Util\ActivityPub\Inbox;
use App\Util\ActivityPub\Validator\QuoteRequestValidator;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@ -734,3 +735,264 @@ describe('api', function () {
expect($status->fresh()->quote_policy)->toBeNull();
});
});
/*
|--------------------------------------------------------------------------
| Additional coverage (regression + new)
|--------------------------------------------------------------------------
*/
describe('policy resolution units', function () {
it('forces nobody for a remote profile regardless of settings', function () {
$bob = quoteRemoteProfile();
expect(QuoteService::accountPolicy($bob))->toBe(QuoteService::POLICY_NOBODY);
});
it('falls back to everyone for an unknown can_quote value', function () {
$user = quoteLocalUser();
$settings = $user->settings;
$settings->can_quote = 'garbage-value';
$settings->save();
QuoteService::forgetPolicy($user->profile->id);
expect(QuoteService::accountPolicy($user->profile))->toBe(QuoteService::POLICY_EVERYONE);
});
it('caches the account policy until forgetPolicy clears it', function () {
$user = quoteLocalUser();
$profileId = $user->profile->id;
// Warm the cache at the default.
expect(QuoteService::accountPolicy($user->profile))->toBe(QuoteService::POLICY_EVERYONE);
// Change the stored setting behind the cache's back.
$settings = $user->settings;
$settings->can_quote = 'followers';
$settings->save();
// Still served from cache (re-read the profile so it is not the stale
// in-memory relation but the value still comes from the warmed cache).
expect(QuoteService::accountPolicy(Profile::find($profileId)))->toBe(QuoteService::POLICY_EVERYONE);
// After invalidation the new value resolves from the fresh settings.
QuoteService::forgetPolicy($profileId);
expect(QuoteService::accountPolicy(Profile::find($profileId)))->toBe(QuoteService::POLICY_FOLLOWERS);
});
it('maps bitmasks to and from api policy strings', function () {
expect(QuoteService::toApiPolicy(QuoteService::FLAG_PUBLIC))->toBe('public')
->and(QuoteService::toApiPolicy(QuoteService::FLAG_FOLLOWERS))->toBe('followers')
->and(QuoteService::toApiPolicy(QuoteService::FLAGS_NOBODY))->toBe('nobody');
});
it('splits a combined bitmask into automatic and manual bytes', function () {
$flags = QuoteService::FLAG_FOLLOWERS
| (QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT);
expect(QuoteService::automatic($flags))->toBe(QuoteService::FLAG_FOLLOWERS)
->and(QuoteService::manual($flags))->toBe(QuoteService::FLAG_PUBLIC);
});
});
describe('isQuotable structural checks', function () {
it('is not quotable when the post is a reblog', function () {
$user = quoteLocalUser();
$original = quoteLocalStatus($user->profile);
$reblog = quoteLocalStatus($user->profile, ['reblog_of_id' => $original->id]);
expect(QuoteService::isQuotable($reblog))->toBeFalse();
});
it('is not quotable for a private-scope post', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, ['scope' => 'private', 'visibility' => 'private']);
expect(QuoteService::isQuotable($status))->toBeFalse();
});
it('is not quotable when the author account is deleted/suspended', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$profile = $user->profile;
$profile->status = 'delete';
$profile->save();
expect(QuoteService::isQuotable($status->fresh()))->toBeFalse();
});
});
describe('canQuote guards', function () {
it('refuses a local actor', function () {
$author = quoteLocalUser();
$status = quoteLocalStatus($author->profile);
$localActor = quoteLocalUser();
expect(QuoteService::canQuote($status, $localActor->profile))->toBeFalse();
});
it('refuses a suspended remote actor', function () {
$author = quoteLocalUser();
$status = quoteLocalStatus($author->profile);
$bob = quoteRemoteProfile();
$bob->status = 'delete';
$bob->save();
expect(QuoteService::canQuote($status, $bob->fresh()))->toBeFalse();
});
it('allows a remote actor under an everyone policy', function () {
$author = quoteLocalUser();
$status = quoteLocalStatus($author->profile);
$bob = quoteRemoteProfile();
expect(QuoteService::canQuote($status, $bob))->toBeTrue();
});
});
describe('QuoteRequest payload validation', function () {
it('accepts a well-formed QuoteRequest payload', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
expect(QuoteRequestValidator::validate(quoteRequestPayload($bob, $status)))->toBeTrue();
});
it('rejects payloads missing required fields or with the wrong type', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$base = quoteRequestPayload($bob, $status);
foreach (['@context', 'actor', 'object', 'instrument'] as $field) {
$missing = $base;
unset($missing[$field]);
expect(QuoteRequestValidator::validate($missing))->toBeFalse("missing {$field} should fail");
}
expect(QuoteRequestValidator::validate(array_merge($base, ['type' => 'Create'])))->toBeFalse()
->and(QuoteRequestValidator::validate(array_merge($base, ['id' => 'not-a-url'])))->toBeFalse()
->and(QuoteRequestValidator::validate(array_merge($base, ['id' => 'https://x.example/'.str_repeat('a', 600)])))->toBeFalse();
});
});
describe('inbound collision + drops', function () {
it('rejects a second actor requesting a quote_url another actor already holds', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
quoteSeedHosts();
// bob is approved for a given quote_url.
$quoteUrl = $bob->remote_url.'/statuses/1';
QuoteService::authorize($status, $bob, $quoteUrl);
// carol requests using the SAME quote_url (a collision) → Reject, no new stamp.
quoteDeliver($carol, quoteRequestPayload($carol, $status, [
'id' => $carol->remote_url.'/statuses/9/quote',
'instrument' => $quoteUrl,
]));
expect(QuoteAuthorization::count())->toBe(1)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('drops an invalid QuoteRequest without answering', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
// Missing instrument → validator fails → Inbox drops it silently.
$payload = quoteRequestPayload($bob, $status);
unset($payload['instrument']);
quoteDeliver($bob, $payload);
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities())->toBeEmpty();
});
});
describe('revocation guards', function () {
it('revoke is idempotent: a second call does not re-dispatch a Delete', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::revoke($auth);
QuoteService::revoke($auth->fresh());
Queue::assertPushed(RevokeQuoteAuthorizationPipeline::class, 1);
});
it('the pipeline sends nothing when the authorization is missing', function () {
quoteInProduction(fn () => (new RevokeQuoteAuthorizationPipeline(999999999))->handle());
expect(quoteSentActivities())->toBeEmpty();
});
it('the pipeline sends nothing when the authorization is still approved', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
// Not revoked → handle() must early-return without a Delete.
quoteInProduction(fn () => (new RevokeQuoteAuthorizationPipeline($auth->id))->handle());
expect(quoteSentActivities())->toBeEmpty();
});
it('forgetQuote hard-deletes a stamp without federating anything', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$quoteUrl = $bob->remote_url.'/statuses/1';
QuoteService::authorize($status, $bob, $quoteUrl);
QuoteService::forgetQuote($bob->id, $quoteUrl);
expect(QuoteAuthorization::count())->toBe(0);
Queue::assertNotPushed(RevokeQuoteAuthorizationPipeline::class);
expect(quoteSentActivities())->toBeEmpty();
});
});
describe('sameUrl normalization', function () {
it('ignores scheme/host case and a trailing slash', function () {
expect(QuoteService::sameUrl('https://Remote.Example/users/bob', 'https://remote.example/users/bob'))->toBeTrue()
->and(QuoteService::sameUrl('https://remote.example/users/bob/', 'https://remote.example/users/bob'))->toBeTrue();
});
it('treats different paths and non-string input as not equal', function () {
expect(QuoteService::sameUrl('https://remote.example/users/bob', 'https://remote.example/users/carol'))->toBeFalse()
->and(QuoteService::sameUrl(null, 'https://remote.example/users/bob'))->toBeFalse()
->and(QuoteService::sameUrl('not-a-url', 'also-not'))->toBeFalse();
});
});
describe('privacy settings guards', function () {
it('ignores an invalid can_quote value', function () {
$user = quoteLocalUser();
$this->actingAs($user->fresh())
->post('/settings/privacy', ['can_quote' => 'not-a-policy'])
->assertRedirect();
// The stored setting is unchanged (still the default) and the account
// policy stays everyone.
QuoteService::forgetPolicy($user->profile->id);
expect($user->settings()->first()->can_quote)->not->toBe('not-a-policy')
->and(QuoteService::accountPolicy($user->profile->fresh()))->toBe(QuoteService::POLICY_EVERYONE);
});
});

Loading…
Cancel
Save