diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..f9c2b14fe7701c945a7e1d3105d5518e6751401e --- /dev/null +++ b/app/controllers/activity_pub/application_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActivityPub + class ApplicationController < ::ApplicationController + include RoutableActions + + before_action :ensure_feature_flag + skip_before_action :authenticate_user! + after_action :set_content_type + + def can?(object, action, subject = :global) + Ability.allowed?(object, action, subject) + end + + def route_not_found + head :not_found + end + + def set_content_type + self.content_type = "application/activity+json" + end + + def ensure_feature_flag + not_found unless ::Feature.enabled?(:activity_pub) + end + end +end diff --git a/app/controllers/activity_pub/projects/application_controller.rb b/app/controllers/activity_pub/projects/application_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..e54a457743d4dcbddfe5da8adc9712efb81724f8 --- /dev/null +++ b/app/controllers/activity_pub/projects/application_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ApplicationController < ::ActivityPub::ApplicationController + before_action :project + before_action :ensure_project_feature_flag + + private + + def project + return unless params[:project_id] || params[:id] + + path = File.join(params[:namespace_id], params[:project_id] || params[:id]) + + @project = find_routable!(Project, path, request.fullpath, extra_authorization_proc: auth_proc) + end + + def auth_proc + ->(project) { project.public? && !project.pending_delete? } + end + + def ensure_project_feature_flag + not_found unless ::Feature.enabled?(:activity_pub_project, project) + end + end + end +end diff --git a/app/controllers/activity_pub/projects/releases_controller.rb b/app/controllers/activity_pub/projects/releases_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c4c2a0322be69108a07ef334f2dcdde39cc3b44 --- /dev/null +++ b/app/controllers/activity_pub/projects/releases_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesController < ApplicationController + feature_category :release_orchestration + + def index + opts = { + inbox: nil, + outbox: outbox_project_releases_url(@project) + } + + render json: ActivityPub::ReleasesActorSerializer.new.represent(@project, opts) + end + + def outbox + serializer = ActivityPub::ReleasesOutboxSerializer.new.with_pagination(request, response) + render json: serializer.represent(releases) + end + + private + + def releases(params = {}) + ReleasesFinder.new(@project, current_user, params).execute + end + end + end +end diff --git a/app/serializers/activity_pub/activity_streams_serializer.rb b/app/serializers/activity_pub/activity_streams_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..39caa4a6d10e43af13bbeb127a7737423edd497f --- /dev/null +++ b/app/serializers/activity_pub/activity_streams_serializer.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module ActivityPub + class ActivityStreamsSerializer < ::BaseSerializer + MissingIdentifierError = Class.new(StandardError) + MissingTypeError = Class.new(StandardError) + MissingOutboxError = Class.new(StandardError) + + alias_method :base_represent, :represent + + def represent(resource, opts = {}, entity_class = nil) + response = if respond_to?(:paginated?) && paginated? + represent_paginated(resource, opts, entity_class) + else + represent_whole(resource, opts, entity_class) + end + + validate_response(HashWithIndifferentAccess.new(response)) + end + + private + + def validate_response(response) + unless response[:id].present? + raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field." + end + + unless response[:type].present? + raise MissingTypeError, "The serializer does not provide the mandatory 'type' field." + end + + response + end + + def represent_whole(resource, opts, entity_class) + raise MissingOutboxError, 'Please provide an :outbox option for this actor' unless opts[:outbox].present? + + serialized = base_represent(resource, opts, entity_class) + + { + :@context => "https://www.w3.org/ns/activitystreams", + inbox: opts[:inbox], + outbox: opts[:outbox] + }.merge(serialized) + end + + def represent_paginated(resources, opts, entity_class) + if paginator.params['page'].present? + represent_page(resources, resources.current_page, opts, entity_class) + else + represent_pagination_index(resources) + end + end + + def represent_page(resources, page, opts, entity_class) + opts[:page] = page + serialized = base_represent(resources, opts, entity_class) + + { + :@context => 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollectionPage', + id: collection_url(page), + prev: page > 1 ? collection_url(page - 1) : nil, + next: page < resources.total_pages ? collection_url(page + 1) : nil, + partOf: collection_url, + orderedItems: serialized + } + end + + def represent_pagination_index(resources) + { + :@context => 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: collection_url, + totalItems: resources.total_count, + first: collection_url(1), + last: collection_url(resources.total_pages) + } + end + + def collection_url(page = nil) + uri = URI.parse(paginator.request.url) + uri.query ||= "" + parts = uri.query.split('&').reject { |part| part =~ /^page=/ } + parts << "page=#{page}" if page + uri.query = parts.join('&') + uri.to_s.sub(/\?$/, '') + end + end +end diff --git a/app/serializers/activity_pub/project_entity.rb b/app/serializers/activity_pub/project_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..02ed0cdc0479e0158b4d49e80789cccb2abf01a1 --- /dev/null +++ b/app/serializers/activity_pub/project_entity.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActivityPub + class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |project| + project_url(project) + end + + expose :type do |*| + "Application" + end + + expose :name + + expose :description, as: :summary + + expose :url do |project| + project_url(project) + end + end +end diff --git a/app/serializers/activity_pub/release_entity.rb b/app/serializers/activity_pub/release_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..9e3e5397034c42961438ad274146968ce6be18ab --- /dev/null +++ b/app/serializers/activity_pub/release_entity.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleaseEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |release, opts| + "#{opts[:url]}##{release.tag}" + end + + expose :type do |*| + "Create" + end + + expose :to do |*| + 'https://www.w3.org/ns/activitystreams#Public' + end + + expose :author, as: :actor, using: UserEntity + + expose :object do + expose :id do |release| + project_release_url(release.project, release) + end + + expose :type do |*| + "Application" + end + + expose :name + + expose :url do |release| + project_release_url(release.project, release) + end + + expose :description, as: :content + expose :project, as: :context, using: ProjectEntity + end + end +end diff --git a/app/serializers/activity_pub/releases_actor_entity.rb b/app/serializers/activity_pub/releases_actor_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..c52741c73a57576b4318b850b8a4a1abaf194a85 --- /dev/null +++ b/app/serializers/activity_pub/releases_actor_entity.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesActorEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |project| + project_releases_url(project) + end + + expose :type do |*| + "Application" + end + + expose :path, as: :preferredUsername do |project| + "#{project.path}-releases" + end + + expose :name do |project| + "#{_('Releases')} - #{project.name}" + end + + expose :description, as: :content + + expose nil, using: ProjectEntity, as: :context do |project| + project + end + end +end diff --git a/app/serializers/activity_pub/releases_actor_serializer.rb b/app/serializers/activity_pub/releases_actor_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..5bae83f2dc71368f92e260407f9d1474c24f5a56 --- /dev/null +++ b/app/serializers/activity_pub/releases_actor_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesActorSerializer < ActivityStreamsSerializer + entity ReleasesActorEntity + end +end diff --git a/app/serializers/activity_pub/releases_outbox_serializer.rb b/app/serializers/activity_pub/releases_outbox_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6d4e633fb01deb1f224ebc8b3d1bb7364ed8499 --- /dev/null +++ b/app/serializers/activity_pub/releases_outbox_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesOutboxSerializer < ActivityStreamsSerializer + include WithPagination + + entity ReleaseEntity + end +end diff --git a/app/serializers/activity_pub/user_entity.rb b/app/serializers/activity_pub/user_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd0886db5b2f439d0e6b28887711ce26c5057d5e --- /dev/null +++ b/app/serializers/activity_pub/user_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActivityPub + class UserEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |user| + user_url(user) + end + + expose :type do |*| + 'Person' + end + + expose :name + expose :username, as: :preferredUsername + + expose :url do |user| + user_url(user) + end + end +end diff --git a/config/feature_flags/development/activity_pub.yml b/config/feature_flags/development/activity_pub.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a7cf9862a53d10cf9b244559cb9febac09412c1 --- /dev/null +++ b/config/feature_flags/development/activity_pub.yml @@ -0,0 +1,8 @@ +--- +name: activity_pub +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127023 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/424008 +milestone: '16.4' +type: development +group: group::source code +default_enabled: false \ No newline at end of file diff --git a/config/feature_flags/development/activity_pub_project.yml b/config/feature_flags/development/activity_pub_project.yml new file mode 100644 index 0000000000000000000000000000000000000000..6630333ad5e03417e26cb050b2c807aa7e0ce6b7 --- /dev/null +++ b/config/feature_flags/development/activity_pub_project.yml @@ -0,0 +1,8 @@ +--- +name: activity_pub_project +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127023 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/424008 +milestone: '16.4' +type: development +group: group::source code +default_enabled: false \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index bdc6a3930d1e40fddb85593211cbda3a29ca47ce..5a99ad44a824502df6d9cdf077eeaadc5cd8b87e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -264,6 +264,7 @@ draw :git_http draw :api + draw :activity_pub draw :customers_dot draw :sidekiq draw :help diff --git a/config/routes/activity_pub.rb b/config/routes/activity_pub.rb new file mode 100644 index 0000000000000000000000000000000000000000..f400d722e7612c545092539673ccbdbf7cf65455 --- /dev/null +++ b/config/routes/activity_pub.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +constraints(::Constraints::ActivityPubConstrainer.new) do + scope(module: 'activity_pub') do + constraints(::Constraints::ProjectUrlConstrainer.new) do + # Emulating route structure from routes/project.rb since we want to serve + # ActivityPub content with the proper "Accept" header to the same urls. See + # project routes file for rational behind this structure. + scope( + path: '*namespace_id', + as: :namespace, + namespace_id: Gitlab::PathRegex.full_namespace_route_regex + ) do + scope( + path: ':project_id', + constraints: { project_id: Gitlab::PathRegex.project_route_regex }, + module: :projects, + as: :project + ) do + scope '-' do + resources :releases, only: :index do + collection do + get 'outbox' + end + end + end + end + end + end + end +end diff --git a/lib/constraints/activity_pub_constrainer.rb b/lib/constraints/activity_pub_constrainer.rb new file mode 100644 index 0000000000000000000000000000000000000000..383de2a84ea4977810d240ba9ed6fb401fafc5e9 --- /dev/null +++ b/lib/constraints/activity_pub_constrainer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Constraints + class ActivityPubConstrainer + def matches?(request) + mime_types.any? { |m| request.headers['Accept'].include?(m) } + end + + private + + def mime_types + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'] + end + end +end diff --git a/spec/controllers/activity_pub/projects/releases_controller_spec.rb b/spec/controllers/activity_pub/projects/releases_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8719756b2604b355d52ef09fc512c4765a6a4f45 --- /dev/null +++ b/spec/controllers/activity_pub/projects/releases_controller_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::Projects::ReleasesController, feature_category: :groups_and_projects do + include AccessMatchersForController + + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:private_project) { create(:project, :repository, :private) } + let_it_be(:developer) { create(:user) } + let_it_be(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) } + let_it_be(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) } + + before_all do + project.add_developer(developer) + end + + shared_examples 'common access controls' do + it 'renders a 200' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when the project is private' do + let(:project) { private_project } + + context 'when user is not logged in' do + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is a developer' do + before do + sign_in(developer) + end + + it 'still renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when activity_pub feature flag is disabled' do + before do + stub_feature_flags(activity_pub: false) + end + + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when activity_pub_project feature flag is disabled' do + before do + stub_feature_flags(activity_pub_project: false) + end + + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + shared_examples_for 'ActivityPub response' do + it 'returns an application/activity+json content_type' do + expect(response.media_type).to eq 'application/activity+json' + end + + it 'is formated as an ActivityStream document' do + expect(json_response['@context']).to eq 'https://www.w3.org/ns/activitystreams' + end + end + + describe 'GET #index' do + before do + get(action, params: params) + end + + let(:action) { :index } + let(:params) { { namespace_id: project.namespace, project_id: project } } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases actor profile data" do + expect(json_response['id']).to include project_releases_path(project) + end + end + + describe 'GET #outbox' do + before do + get(action, params: params) + end + + let(:action) { :outbox } + let(:params) { { namespace_id: project.namespace, project_id: project, page: page } } + + context 'with no page parameter' do + let(:page) { nil } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases collection index" do + expect(json_response['id']).to include outbox_project_releases_path(project) + expect(json_response['totalItems']).to eq 2 + end + end + + context 'with a page parameter' do + let(:page) { 1 } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases list" do + expect(json_response['id']).to include outbox_project_releases_path(project, page: 1) + + names = json_response['orderedItems'].map { |release| release['object']['name'] } + expect(names).to match_array([release_2.name, release_1.name]) + end + end + end +end diff --git a/spec/serializers/activity_pub/activity_streams_serializer_spec.rb b/spec/serializers/activity_pub/activity_streams_serializer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c74beba7a81265a7442cf3abdc85c6cb98687dce --- /dev/null +++ b/spec/serializers/activity_pub/activity_streams_serializer_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ActivityStreamsSerializer, feature_category: :integrations do + let(:implementer_class) do + Class.new(described_class) do + include WithPagination + end + end + + let(:entity_class) do + Class.new(Grape::Entity) do + expose :id do |*| + 'https://example.com/unique/url' + end + + expose :type do |*| + 'Person' + end + + expose :name do |*| + 'Alice' + end + end + end + + shared_examples_for 'ActivityStreams document' do + it 'belongs to the ActivityStreams namespace' do + expect(subject['@context']).to eq 'https://www.w3.org/ns/activitystreams' + end + + it 'has a unique identifier' do + expect(subject).to have_key 'id' + end + + it 'has a type' do + expect(subject).to have_key 'type' + end + end + + before do + implementer_class.entity entity_class + end + + context 'when the serializer is not paginated' do + let(:resource) { build_stubbed(:release) } + let(:outbox_url) { 'https://example.com/unique/url/outbox' } + + context 'with a valid represented entity' do + subject { implementer_class.new.represent(resource, outbox: outbox_url) } + + it_behaves_like 'ActivityStreams document' + + it 'exposes an outbox' do + expect(subject['outbox']).to eq 'https://example.com/unique/url/outbox' + end + + it 'includes serialized data' do + expect(subject['name']).to eq 'Alice' + end + end + + context 'when the represented entity provides no identifier' do + subject { implementer_class.new.represent(resource, outbox: outbox_url) } + + before do + allow(entity_class).to receive(:represent).and_return({ type: 'Person' }) + end + + it 'raises an exception' do + expect { subject }.to raise_error(ActivityPub::ActivityStreamsSerializer::MissingIdentifierError) + end + end + + context 'when the represented entity provides no type' do + subject { implementer_class.new.represent(resource, outbox: outbox_url) } + + before do + allow(entity_class).to receive(:represent).and_return({ id: 'https://example.com/unique/url' }) + end + + it 'raises an exception' do + expect { subject }.to raise_error(ActivityPub::ActivityStreamsSerializer::MissingTypeError) + end + end + + context 'when the caller provides no outbox parameter' do + subject { implementer_class.new.represent(resource) } + + it 'raises an exception' do + expect { subject }.to raise_error(ActivityPub::ActivityStreamsSerializer::MissingOutboxError) + end + end + end + + context 'when the serializer is paginated' do + let(:resources) { build_stubbed_list(:release, 3) } + let(:request) { ActionDispatch::Request.new(request_data) } + let(:response) { ActionDispatch::Response.new } + let(:url) { 'https://example.com/resource/url' } + let(:decorated) { implementer_class.new.with_pagination(request, response) } + + before do + allow(resources).to receive(:page).and_return(resources) + allow(resources).to receive(:per).and_return(resources) + allow(resources).to receive(:current_page).and_return(2) + allow(resources).to receive(:total_pages).and_return(3) + allow(resources).to receive(:total_count).and_return(10) + allow(decorated.paginator).to receive(:paginate).and_return(resources) + end + + context 'when no page parameter is provided' do + subject { decorated.represent(resources) } + + let(:request_data) do + { "rack.url_scheme" => "https", "HTTP_HOST" => "example.com", "PATH_INFO" => '/resource/url' } + end + + it_behaves_like 'ActivityStreams document' + + it 'is an index document for the pagination' do + expect(subject['type']).to eq 'OrderedCollection' + end + + it 'contains the total amount of items' do + expect(subject['totalItems']).to eq 10 + end + + it 'contains links to first and last page' do + expect(subject['first']).to eq "#{url}?page=1" + expect(subject['last']).to eq "#{url}?page=3" + end + end + + context 'when a page parameter is provided' do + subject { decorated.represent(resources) } + + let(:request_data) do + { 'rack.url_scheme' => 'https', 'HTTP_HOST' => 'example.com', 'PATH_INFO' => '/resource/url', + 'QUERY_STRING' => 'page=2&per_page=1' } + end + + it_behaves_like 'ActivityStreams document' + + it 'is a page document' do + expect(subject['type']).to eq 'OrderedCollectionPage' + end + + it 'contains navigation links' do + expect(subject['prev']).to be_present + expect(subject['next']).to be_present + expect(subject['partOf']).to be_present + end + end + end +end diff --git a/spec/serializers/activity_pub/project_entity_spec.rb b/spec/serializers/activity_pub/project_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f273acace737f0cdd80bd67855530c483e315159 --- /dev/null +++ b/spec/serializers/activity_pub/project_entity_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ProjectEntity, feature_category: :groups_and_projects do + let(:project) { build_stubbed(:project, name: 'Fooify', path: 'fooify') } + let(:entity) { described_class.new(project) } + + context 'as json' do + subject { entity.as_json } + + it 'has releases page as id' do + expect(subject[:id]).to match(%r{/fooify$}) + end + + it 'is an Application actor' do + expect(subject[:type]).to eq 'Application' + end + + it 'provides project name' do + expect(subject[:name]).to eq project.name + end + + it 'provides a description of the project' do + expect(subject[:summary]).to eq project.description + end + + it 'provides an url for web content' do + expect(subject[:url]).to match(%r{/fooify$}) + end + end +end diff --git a/spec/serializers/activity_pub/release_entity_spec.rb b/spec/serializers/activity_pub/release_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a473fbcc2bdbfee067d3fc0bc545edb5ddcb3b64 --- /dev/null +++ b/spec/serializers/activity_pub/release_entity_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ReleaseEntity, feature_category: :groups_and_projects do + let(:release) { build_stubbed(:release) } + let(:entity) { described_class.new(release, url: '/outbox') } + + context 'as json' do + subject { entity.as_json } + + it 'has tag as id' do + expect(subject[:id]).to match(/##{release.tag}$/) + end + + it 'is a Create activity' do + expect(subject[:type]).to eq 'Create' + end + + it 'is addressed to public' do + expect(subject[:to]).to eq 'https://www.w3.org/ns/activitystreams#Public' + end + + it 'has an author' do + expect(subject[:actor]).to include(:id, :type, :name, :preferredUsername, :url) + end + + it 'embeds the release as an Application actor' do + expect(subject[:object][:type]).to eq 'Application' + end + + it 'provides release name' do + expect(subject[:object][:name]).to eq release.name + end + + it 'provides release description' do + expect(subject[:object][:content]).to eq release.description + end + + it 'provides an url for web content' do + expect(subject[:object][:url]).to include release.tag + end + + it 'provides project data as context' do + expect(subject[:object][:context]).to include(:id, :type, :name, :summary, :url) + end + end +end diff --git a/spec/serializers/activity_pub/releases_actor_entity_spec.rb b/spec/serializers/activity_pub/releases_actor_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe3889688671ebc9e1b66d9b4541031d98800093 --- /dev/null +++ b/spec/serializers/activity_pub/releases_actor_entity_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ReleasesActorEntity, feature_category: :groups_and_projects do + let(:project) { build_stubbed(:project, name: 'Fooify', path: 'fooify') } + let(:releases) { build_stubbed_list(:release, 3, project: project) } + + let(:entity) { described_class.new(project) } + + context 'as json' do + subject { entity.as_json } + + it 'has releases page as id' do + expect(subject[:id]).to include "/fooify/-/releases" + end + + it 'is an Application actor' do + expect(subject[:type]).to eq 'Application' + end + + it 'has a recognizable username' do + expect(subject[:preferredUsername]).to include 'releases' + end + + it 'has a recognizable full name' do + expect(subject[:name]).to eq 'Releases - Fooify' + end + + it 'provides a description of the project' do + expect(subject[:content]).to eq project.description + end + + it 'provides project data as context' do + expect(subject[:context]).to include(:id, :type, :name, :summary, :url) + expect(subject[:context][:id]).to match(%r{/fooify$}) + end + end +end diff --git a/spec/serializers/activity_pub/releases_actor_serializer_spec.rb b/spec/serializers/activity_pub/releases_actor_serializer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bc754eabe5c884c82bd1c4684ef8192ee9c3faf7 --- /dev/null +++ b/spec/serializers/activity_pub/releases_actor_serializer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ReleasesActorSerializer, feature_category: :groups_and_projects do + let(:project) { build_stubbed(:project, name: 'Fooify', path: 'fooify') } + let(:releases) { build_stubbed_list(:release, 3, project: project) } + + context 'when there is a single object provided' do + subject { described_class.new.represent(project, outbox: '/outbox') } + + it 'serializes the actor attributes' do + expect(subject).to include(:id, :type, :preferredUsername, :name, :content, :context) + end + end +end diff --git a/spec/serializers/activity_pub/releases_outbox_serializer_spec.rb b/spec/serializers/activity_pub/releases_outbox_serializer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..606b0130e0fb909719f21aadbbc2faa86b269bbd --- /dev/null +++ b/spec/serializers/activity_pub/releases_outbox_serializer_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ReleasesOutboxSerializer, feature_category: :groups_and_projects do + let(:decorated) { described_class.new.with_pagination(request, response) } + + let(:project) { build_stubbed(:project, name: 'Fooify', path: 'fooify') } + let(:releases) { build_stubbed_list(:release, 3, project: project) } + + before do + allow(releases).to receive(:page).and_return(releases) + allow(releases).to receive(:per).and_return(releases) + allow(releases).to receive(:current_page).and_return(1) + allow(releases).to receive(:total_pages).and_return(1) + allow(decorated.paginator).to receive(:paginate).and_return(releases) + end + + context 'when there is a list of objects provided' do + subject { decorated.represent(releases, url: '/outbox') } + + let(:request) { ActionDispatch::Request.new({ 'QUERY_STRING' => 'page=1' }) } + let(:response) { ActionDispatch::Response.new } + + it 'is a OrderedCollection document' do + expect(subject[:type]).to eq 'OrderedCollectionPage' + end + + it 'serializes the releases' do + expect(subject[:orderedItems].count).to eq 3 + expect(subject[:orderedItems][0]).to include(:id, :type, :to, :actor, :object) + end + end +end diff --git a/spec/serializers/activity_pub/user_entity_spec.rb b/spec/serializers/activity_pub/user_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d9ab7a11ecffcb84c79d435d1a257be06c027e62 --- /dev/null +++ b/spec/serializers/activity_pub/user_entity_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::UserEntity, feature_category: :user_profile do + let(:user) { build_stubbed(:user, name: 'Alice', username: 'alice') } + let(:entity) { described_class.new(user) } + + context 'as json' do + subject { entity.as_json } + + it 'has releases page as id' do + expect(subject[:id]).to match(%r{/alice$}) + end + + it 'is a Person actor' do + expect(subject[:type]).to eq 'Person' + end + + it 'provides project name' do + expect(subject[:name]).to eq 'Alice' + end + + it 'provides an url for web content' do + expect(subject[:url]).to match(%r{/alice$}) + end + end +end