Expose feature policy in API (#37322)

This commit is contained in:
David Roetzel 2025-12-19 16:20:30 +01:00 committed by GitHub
parent 8d9192835d
commit 0231b6d350
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 218 additions and 1 deletions

View File

@ -91,6 +91,7 @@ class Account < ApplicationRecord
include Account::FaspConcern
include Account::FinderConcern
include Account::Header
include Account::InteractionPolicyConcern
include Account::Interactions
include Account::Mappings
include Account::Merging

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
module Account::InteractionPolicyConcern
extend ActiveSupport::Concern
included do
composed_of :feature_interaction_policy, class_name: 'InteractionPolicy', mapping: { feature_approval_policy: :bitmap }
end
def feature_policy_as_keys(kind)
raise ArgumentError unless kind.in?(%i(automatic manual))
return local_feature_policy(kind) if local?
sub_policy = feature_interaction_policy.send(kind)
sub_policy.as_keys
end
# Returns `:automatic`, `:manual`, `:unknown`, ':missing` or `:denied`
def feature_policy_for_account(other_account)
return :denied if other_account.nil? || (local? && !discoverable?)
return :automatic if local?
# Post author is always allowed to feature themselves
return :automatic if self == other_account
return :missing if feature_approval_policy.zero?
automatic_policy = feature_interaction_policy.automatic
following_self = nil
followed_by_self = nil
return :automatic if automatic_policy.public?
if automatic_policy.followers?
following_self = followed_by?(other_account)
return :automatic if following_self
end
if automatic_policy.following?
followed_by_self = following?(other_account)
return :automatic if followed_by_self
end
# We don't know we are allowed by the automatic policy, considering the manual one
manual_policy = feature_interaction_policy.manual
return :manual if manual_policy.public?
if manual_policy.followers?
following_self = followed_by?(other_account) if following_self.nil?
return :manual if following_self
end
if manual_policy.following?
followed_by_self = following?(other_account) if followed_by_self.nil?
return :manual if followed_by_self
end
return :unknown if [automatic_policy, manual_policy].any?(&:unsupported_policy?)
:denied
end
private
def local_feature_policy(kind)
return [] if kind == :manual || !discoverable?
[:public]
end
end

View File

@ -20,6 +20,8 @@ class REST::AccountSerializer < ActiveModel::Serializer
attribute :memorial, if: :memorial?
attribute :feature_approval, if: -> { Mastodon::Feature.collections_enabled? }
class AccountDecorator < SimpleDelegator
def self.model_name
Account.model_name
@ -157,4 +159,12 @@ class REST::AccountSerializer < ActiveModel::Serializer
def moved_and_not_nested?
object.moved?
end
def feature_approval
{
automatic: object.feature_policy_as_keys(:automatic),
manual: object.feature_policy_as_keys(:manual),
current_user: object.feature_policy_for_account(current_user&.account),
}
end
end

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account::InteractionPolicyConcern do
describe '#feature_policy_as_keys' do
context 'when account is local' do
context 'when account is discoverable' do
let(:account) { Fabricate(:account) }
it 'returns public for automtatic and nothing for manual' do
expect(account.feature_policy_as_keys(:automatic)).to eq [:public]
expect(account.feature_policy_as_keys(:manual)).to eq []
end
end
context 'when account is not discoverable' do
let(:account) { Fabricate(:account, discoverable: false) }
it 'returns empty arrays for both inputs' do
expect(account.feature_policy_as_keys(:automatic)).to eq []
expect(account.feature_policy_as_keys(:manual)).to eq []
end
end
end
context 'when account is remote' do
let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy: (0b0101 << 16) | 0b0010) }
it 'returns the expected values' do
expect(account.feature_policy_as_keys(:automatic)).to eq ['unsupported_policy', 'followers']
expect(account.feature_policy_as_keys(:manual)).to eq ['public']
end
end
end
describe '#feature_policy_for_account' do
context 'when account is remote' do
let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy:) }
let(:feature_approval_policy) { (0b0101 << 16) | 0b0010 }
let(:other_account) { Fabricate(:account) }
context 'when no policy is available' do
let(:feature_approval_policy) { 0 }
context 'when both accounts are the same' do
it 'returns :automatic' do
expect(account.feature_policy_for_account(account)).to eq :automatic
end
end
context 'with two different accounts' do
it 'returns :missing' do
expect(account.feature_policy_for_account(other_account)).to eq :missing
end
end
end
context 'when the other account is not following the account' do
it 'returns :manual because of the public entry in the manual policy' do
expect(account.feature_policy_for_account(other_account)).to eq :manual
end
end
context 'when the other account is following the account' do
before do
other_account.follow!(account)
end
it 'returns :automatic because of the followers entry in the automatic policy' do
expect(account.feature_policy_for_account(other_account)).to eq :automatic
end
end
context 'when the account falls into the unknown bucket' do
let(:feature_approval_policy) { (0b0001 << 16) | 0b0100 }
it 'returns :automatic because of the followers entry in the automatic policy' do
expect(account.feature_policy_for_account(other_account)).to eq :unknown
end
end
end
end
end

View File

@ -3,12 +3,18 @@
require 'rails_helper'
RSpec.describe REST::AccountSerializer do
subject { serialized_record_json(account, described_class) }
subject do
serialized_record_json(account, described_class, options: {
scope: current_user,
scope_name: :current_user,
})
end
let(:default_datetime) { DateTime.new(2024, 11, 28, 16, 20, 0) }
let(:role) { Fabricate(:user_role, name: 'Role', highlighted: true) }
let(:user) { Fabricate(:user, role: role) }
let(:account) { user.account }
let(:current_user) { Fabricate(:user) }
context 'when the account is suspended' do
before do
@ -68,4 +74,51 @@ RSpec.describe REST::AccountSerializer do
expect(subject['last_status_at']).to eq('2024-11-28')
end
end
describe '#feature_approval' do
# TODO: Remove when feature flag is removed
context 'when collections feature is disabled' do
it 'does not include the approval policy' do
expect(subject).to_not have_key('feature_approval')
end
end
context 'when collections feature is enabled', feature: :collections do
context 'when account is local' do
context 'when account is discoverable' do
it 'includes a policy that allows featuring' do
expect(subject['feature_approval']).to include({
'automatic' => ['public'],
'manual' => [],
'current_user' => 'automatic',
})
end
end
context 'when account is not discoverable' do
let(:account) { Fabricate(:account, discoverable: false) }
it 'includes a policy that disallows featuring' do
expect(subject['feature_approval']).to include({
'automatic' => [],
'manual' => [],
'current_user' => 'denied',
})
end
end
end
context 'when account is remote' do
let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy: 0b11000000000000000010) }
it 'includes the matching policy' do
expect(subject['feature_approval']).to include({
'automatic' => ['followers', 'following'],
'manual' => ['public'],
'current_user' => 'manual',
})
end
end
end
end
end