diff options
48 files changed, 1454 insertions, 156 deletions
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 5d0e39e8195..e106afea9f5 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -40,7 +40,12 @@ export default { <button type="button" class="btn btn-default append-right-10" @click="closeForm"> {{ __('Cancel') }} </button> - <button type="button" class="btn btn-close" @click.prevent="submitForm"> + <button + type="button" + class="btn btn-close" + data-testid="confidential-toggle" + @click.prevent="submitForm" + > {{ toggleButtonText }} </button> </div> diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 3e06fa0957c..5810322b088 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -35,7 +35,8 @@ module Ci lsif: 'lsif.json', dotenv: '.env', cobertura: 'cobertura-coverage.xml', - terraform: 'tfplan.json' + terraform: 'tfplan.json', + cluster_applications: 'gl-cluster-applications.json' }.freeze INTERNAL_TYPES = { @@ -52,6 +53,7 @@ module Ci lsif: :gzip, dotenv: :gzip, cobertura: :gzip, + cluster_applications: :gzip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis @@ -153,7 +155,8 @@ module Ci dotenv: 16, cobertura: 17, terraform: 18, # Transformed json - accessibility: 19 + accessibility: 19, + cluster_applications: 20 } enum file_format: { diff --git a/app/models/event.rb b/app/models/event.rb index 48f745649e4..12b85697690 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -96,6 +96,8 @@ class Event < ApplicationRecord end scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) } + scope :for_wiki_meta, ->(meta) { where(target_type: 'WikiPage::Meta', target_id: meta.id) } + scope :created_at, ->(time) { where(created_at: time) } # Authors are required as they're used to display who pushed data. # diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index b3fc4941b12..319cdd38d93 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -295,6 +295,10 @@ class WikiPage 'wiki_page' end + def version_commit_timestamp + version&.commit&.committed_date + end + private def serialize_front_matter(hash) diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb index 2af7d86ebcc..474968122b1 100644 --- a/app/models/wiki_page/meta.rb +++ b/app/models/wiki_page/meta.rb @@ -5,6 +5,7 @@ class WikiPage include Gitlab::Utils::StrongMemoize CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid) + WikiPageInvalid = Class.new(ArgumentError) self.table_name = 'wiki_page_meta' @@ -23,46 +24,62 @@ class WikiPage alias_method :resource_parent, :project - # Return the (updated) WikiPage::Meta record for a given wiki page - # - # If none is found, then a new record is created, and its fields are set - # to reflect the wiki_page passed. - # - # @param [String] last_known_slug - # @param [WikiPage] wiki_page - # - # As with all `find_or_create` methods, this one raises errors on - # validation issues. - def self.find_or_create(last_known_slug, wiki_page) - project = wiki_page.wiki.project - known_slugs = [last_known_slug, wiki_page.slug].compact.uniq - raise 'no slugs!' if known_slugs.empty? - - transaction do - found = find_by_canonical_slug(known_slugs, project) - meta = found || create(title: wiki_page.title, project_id: project.id) - - meta.update_state(found.nil?, known_slugs, wiki_page) - - # We don't need to run validations here, since find_by_canonical_slug - # guarantees that there is no conflict in canonical_slug, and DB - # constraints on title and project_id enforce our other invariants - # This saves us a query. - meta + class << self + # Return the (updated) WikiPage::Meta record for a given wiki page + # + # If none is found, then a new record is created, and its fields are set + # to reflect the wiki_page passed. + # + # @param [String] last_known_slug + # @param [WikiPage] wiki_page + # + # This method raises errors on validation issues. + def find_or_create(last_known_slug, wiki_page) + raise WikiPageInvalid unless wiki_page.valid? + + project = wiki_page.wiki.project + known_slugs = [last_known_slug, wiki_page.slug].compact.uniq + raise 'No slugs found! This should not be possible.' if known_slugs.empty? + + transaction do + updates = wiki_page_updates(wiki_page) + found = find_by_canonical_slug(known_slugs, project) + meta = found || create!(updates.merge(project_id: project.id)) + + meta.update_state(found.nil?, known_slugs, wiki_page, updates) + + # We don't need to run validations here, since find_by_canonical_slug + # guarantees that there is no conflict in canonical_slug, and DB + # constraints on title and project_id enforce our other invariants + # This saves us a query. + meta + end end - end - def self.find_by_canonical_slug(canonical_slug, project) - meta, conflict = with_canonical_slug(canonical_slug) - .where(project_id: project.id) - .limit(2) + def find_by_canonical_slug(canonical_slug, project) + meta, conflict = with_canonical_slug(canonical_slug) + .where(project_id: project.id) + .limit(2) - if conflict.present? - meta.errors.add(:canonical_slug, 'Duplicate value found') - raise CanonicalSlugConflictError.new(meta) + if conflict.present? + meta.errors.add(:canonical_slug, 'Duplicate value found') + raise CanonicalSlugConflictError.new(meta) + end + + meta end - meta + private + + def wiki_page_updates(wiki_page) + last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc + + { + title: wiki_page.title, + created_at: last_commit_date, + updated_at: last_commit_date + } + end end def canonical_slug @@ -85,24 +102,21 @@ class WikiPage @canonical_slug = slug end - def update_state(created, known_slugs, wiki_page) - update_wiki_page_attributes(wiki_page) + def update_state(created, known_slugs, wiki_page, updates) + update_wiki_page_attributes(updates) insert_slugs(known_slugs, created, wiki_page.slug) self.canonical_slug = wiki_page.slug end - def update_columns(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.now.utc)) - end - - def self.update_all(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.now.utc)) - end - private - def update_wiki_page_attributes(page) - update_columns(title: page.title) unless page.title == title + def update_wiki_page_attributes(updates) + # Remove all unnecessary updates: + updates.delete(:updated_at) if updated_at == updates[:updated_at] + updates.delete(:created_at) if created_at <= updates[:created_at] + updates.delete(:title) if title == updates[:title] + + update_columns(updates) unless updates.empty? end def insert_slugs(strings, is_new, canonical_slug) diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 0b044e1679a..522f36cda46 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -85,18 +85,40 @@ class EventCreateService # Create a new wiki page event # # @param [WikiPage::Meta] wiki_page_meta The event target - # @param [User] current_user The event author + # @param [User] author The event author # @param [Integer] action One of the Event::WIKI_ACTIONS - def wiki_event(wiki_page_meta, current_user, action) + # + # @return a tuple of event and either :found or :created + def wiki_event(wiki_page_meta, author, action) return unless Feature.enabled?(:wiki_events) raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action) - create_record_event(wiki_page_meta, current_user, action) + if duplicate = existing_wiki_event(wiki_page_meta, action) + return duplicate + end + + event = create_record_event(wiki_page_meta, author, action) + # Ensure that the event is linked in time to the metadata, for non-deletes + unless action == Event::DESTROYED + time_stamp = wiki_page_meta.updated_at + event.update_columns(updated_at: time_stamp, created_at: time_stamp) + end + + event end private + def existing_wiki_event(wiki_page_meta, action) + if action == Event::DESTROYED + most_recent = Event.for_wiki_meta(wiki_page_meta).recent.first + return most_recent if most_recent.present? && most_recent.action == action + else + Event.for_wiki_meta(wiki_page_meta).created_at(wiki_page_meta.updated_at).first + end + end + def create_record_event(record, current_user, status) create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name) end diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index d4267d4a3c5..8bdbc28f3e8 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -2,8 +2,63 @@ module Git class WikiPushService < ::BaseService + # Maximum number of change events we will process on any single push + MAX_CHANGES = 100 + def execute - # This is used in EE + process_changes + end + + private + + def process_changes + return unless can_process_wiki_events? + + push_changes.take(MAX_CHANGES).each do |change| # rubocop:disable CodeReuse/ActiveRecord + next unless change.page.present? + + response = create_event_for(change) + log_error(response.message) if response.error? + end + end + + def can_process_wiki_events? + Feature.enabled?(:wiki_events) && Feature.enabled?(:wiki_events_on_git_push, project) + end + + def push_changes + default_branch_changes.flat_map do |change| + raw_changes(change).map { |raw| Git::WikiPushService::Change.new(wiki, change, raw) } + end + end + + def raw_changes(change) + wiki.repository.raw.raw_changes_between(change[:oldrev], change[:newrev]) + end + + def wiki + project.wiki + end + + def create_event_for(change) + event_service.execute(change.last_known_slug, change.page, change.event_action) + end + + def event_service + @event_service ||= WikiPages::EventCreateService.new(current_user) + end + + def on_default_branch?(change) + project.wiki.default_branch == ::Gitlab::Git.branch_name(change[:ref]) + end + + # See: [Gitlab::GitPostReceive#changes] + def changes + params[:changes] || [] + end + + def default_branch_changes + @default_branch_changes ||= changes.select { |change| on_default_branch?(change) } end end end diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb new file mode 100644 index 00000000000..8685850165a --- /dev/null +++ b/app/services/git/wiki_push_service/change.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Git + class WikiPushService + class Change + include Gitlab::Utils::StrongMemoize + + # @param [ProjectWiki] wiki + # @param [Hash] change - must have keys `:oldrev` and `:newrev` + # @param [Gitlab::Git::RawDiffChange] raw_change + def initialize(project_wiki, change, raw_change) + @wiki, @raw_change, @change = project_wiki, raw_change, change + end + + def page + strong_memoize(:page) { wiki.find_page(slug, revision) } + end + + # See [Gitlab::Git::RawDiffChange#extract_operation] for the + # definition of the full range of operation values. + def event_action + case raw_change.operation + when :added + Event::CREATED + when :deleted + Event::DESTROYED + else + Event::UPDATED + end + end + + def last_known_slug + strip_extension(raw_change.old_path || raw_change.new_path) + end + + private + + attr_reader :raw_change, :change, :wiki + + def filename + return raw_change.old_path if deleted? + + raw_change.new_path + end + + def slug + strip_extension(filename) + end + + def revision + return change[:oldrev] if deleted? + + change[:newrev] + end + + def deleted? + raw_change.operation == :deleted + end + + def strip_extension(filename) + return unless filename + + File.basename(filename, File.extname(filename)) + end + end + end +end diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb index 2e774973ca5..2a2cbd7f7be 100644 --- a/app/services/wiki_pages/base_service.rb +++ b/app/services/wiki_pages/base_service.rb @@ -46,12 +46,9 @@ module WikiPages def create_wiki_event(page) return unless ::Feature.enabled?(:wiki_events) - slug = slug_for_page(page) + response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action) - Event.transaction do - wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) - EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action) - end + log_error(response.message) if response.error? end def slug_for_page(page) diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb new file mode 100644 index 00000000000..18a45d057a9 --- /dev/null +++ b/app/services/wiki_pages/event_create_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module WikiPages + class EventCreateService + # @param [User] author The event author + def initialize(author) + raise ArgumentError, 'author must not be nil' unless author + + @author = author + end + + def execute(slug, page, action) + return ServiceResponse.success(message: 'No event created as `wiki_events` feature is disabled') unless ::Feature.enabled?(:wiki_events) + + event = Event.transaction do + wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) + + ::EventCreateService.new.wiki_event(wiki_page_meta, author, action) + end + + ServiceResponse.success(payload: { event: event }) + rescue ::EventCreateService::IllegalActionError, ::ActiveRecord::ActiveRecordError => e + ServiceResponse.error(message: e.message, payload: { error: e }) + end + + private + + attr_reader :author + end +end diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 52964dd6739..3151368bb3f 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -271,11 +271,6 @@ = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do %span = _('Network') - - if template_exists?('admin/geo/settings/show') - = nav_link do - = link_to geo_admin_application_settings_path, title: _('Geo') do - %span - = _('Geo') = nav_link(path: 'application_settings#preferences') do = link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do %span diff --git a/changelogs/unreleased/30526-c-be-wiki-activity-Pushes.yml b/changelogs/unreleased/30526-c-be-wiki-activity-Pushes.yml new file mode 100644 index 00000000000..95b5abbb55b --- /dev/null +++ b/changelogs/unreleased/30526-c-be-wiki-activity-Pushes.yml @@ -0,0 +1,5 @@ +--- +title: Create Wiki activity events on pushes to Wiki git repository +merge_request: 26624 +author: +type: added diff --git a/changelogs/unreleased/cluster-applications-artifact.yml b/changelogs/unreleased/cluster-applications-artifact.yml new file mode 100644 index 00000000000..d027fcc11bc --- /dev/null +++ b/changelogs/unreleased/cluster-applications-artifact.yml @@ -0,0 +1,5 @@ +--- +title: Add support for cluster applications CI artifact report +merge_request: 28866 +author: +type: added diff --git a/changelogs/unreleased/remove_admin_settings_geo_navigation.yml b/changelogs/unreleased/remove_admin_settings_geo_navigation.yml new file mode 100644 index 00000000000..4635c8a7ca0 --- /dev/null +++ b/changelogs/unreleased/remove_admin_settings_geo_navigation.yml @@ -0,0 +1,5 @@ +--- +title: Remove Admin -> Settings -> Geo navigation +merge_request: 21005 +author: Lee Tickett +type: other diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index be01540a293..697d62c9d79 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # GitLab Managed Apps GitLab provides **GitLab Managed Apps**, a one-click install for various applications which can diff --git a/doc/user/clusters/crossplane.md b/doc/user/clusters/crossplane.md index 4e2ae87ecb9..9a1dde52956 100644 --- a/doc/user/clusters/crossplane.md +++ b/doc/user/clusters/crossplane.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Crossplane configuration Once Crossplane [is installed](applications.md#crossplane), it must be configured for diff --git a/doc/user/clusters/environments.md b/doc/user/clusters/environments.md index f83be85726a..ba96eef1e01 100644 --- a/doc/user/clusters/environments.md +++ b/doc/user/clusters/environments.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Cluster Environments **(PREMIUM)** > - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13392) for group-level clusters in [GitLab Premium](https://about.gitlab.com/pricing/) 12.3. diff --git a/doc/user/clusters/management_project.md b/doc/user/clusters/management_project.md index 2b8ed83bdb2..03b4dc45015 100644 --- a/doc/user/clusters/management_project.md +++ b/doc/user/clusters/management_project.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Cluster management project (alpha) CAUTION: **Warning:** diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md index f15ad2165de..35fff638849 100644 --- a/doc/user/group/clusters/index.md +++ b/doc/user/group/clusters/index.md @@ -1,5 +1,8 @@ --- type: reference +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers --- # Group-level Kubernetes clusters diff --git a/doc/user/infrastructure/index.md b/doc/user/infrastructure/index.md index a1d09373e2c..e4ad6244bfe 100644 --- a/doc/user/infrastructure/index.md +++ b/doc/user/infrastructure/index.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Infrastructure as code with GitLab managed Terraform State [Terraform remote backends](https://www.terraform.io/docs/backends/index.html) diff --git a/doc/user/project/clusters/add_eks_clusters.md b/doc/user/project/clusters/add_eks_clusters.md index 7fa8ec6c5f3..712f8ea0adc 100644 --- a/doc/user/project/clusters/add_eks_clusters.md +++ b/doc/user/project/clusters/add_eks_clusters.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Adding EKS clusters GitLab supports adding new and existing EKS clusters. diff --git a/doc/user/project/clusters/add_gke_clusters.md b/doc/user/project/clusters/add_gke_clusters.md index 1195421f8fb..4094828323a 100644 --- a/doc/user/project/clusters/add_gke_clusters.md +++ b/doc/user/project/clusters/add_gke_clusters.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Adding GKE clusters GitLab supports adding new and existing GKE clusters. diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md index dce273ce602..fddc9873f17 100644 --- a/doc/user/project/clusters/add_remove_clusters.md +++ b/doc/user/project/clusters/add_remove_clusters.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Adding and removing Kubernetes clusters GitLab offers integrated cluster creation for the following Kubernetes providers: diff --git a/doc/user/project/clusters/kubernetes_pod_logs.md b/doc/user/project/clusters/kubernetes_pod_logs.md index 1b7a6968e15..3d06a2cf0df 100644 --- a/doc/user/project/clusters/kubernetes_pod_logs.md +++ b/doc/user/project/clusters/kubernetes_pod_logs.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Kubernetes Logs > - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4752) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.0. diff --git a/doc/user/project/clusters/runbooks/index.md b/doc/user/project/clusters/runbooks/index.md index 279f08c2a72..dfed43470bc 100644 --- a/doc/user/project/clusters/runbooks/index.md +++ b/doc/user/project/clusters/runbooks/index.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Runbooks Runbooks are a collection of documented procedures that explain how to diff --git a/doc/user/project/clusters/serverless/aws.md b/doc/user/project/clusters/serverless/aws.md index 50cc0ef8d8d..124a0d4bf9f 100644 --- a/doc/user/project/clusters/serverless/aws.md +++ b/doc/user/project/clusters/serverless/aws.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Deploying AWS Lambda function using GitLab CI/CD GitLab allows users to easily deploy AWS Lambda functions and create rich serverless applications. diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index 418e16aa0c1..2156d96f92a 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -1,3 +1,9 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Serverless > Introduced in GitLab 11.5. diff --git a/lib/gitlab/ci/config/entry/reports.rb b/lib/gitlab/ci/config/entry/reports.rb index 8bc6b96ac69..1a871e043a6 100644 --- a/lib/gitlab/ci/config/entry/reports.rb +++ b/lib/gitlab/ci/config/entry/reports.rb @@ -14,7 +14,7 @@ module Gitlab ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management license_scanning metrics lsif - dotenv cobertura terraform accessibility].freeze + dotenv cobertura terraform accessibility cluster_applications].freeze attributes ALLOWED_KEYS @@ -38,6 +38,7 @@ module Gitlab validates :cobertura, array_of_strings_or_string: true validates :terraform, array_of_strings_or_string: true validates :accessibility, array_of_strings_or_string: true + validates :cluster_applications, array_of_strings_or_string: true end end diff --git a/lib/gitlab/kubernetes/network_policy.rb b/lib/gitlab/kubernetes/network_policy.rb new file mode 100644 index 00000000000..8a93cd100d7 --- /dev/null +++ b/lib/gitlab/kubernetes/network_policy.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + class NetworkPolicy + def initialize(name:, namespace:, pod_selector:, ingress:, creation_timestamp: nil, policy_types: ["Ingress"], egress: nil) + @name = name + @namespace = namespace + @creation_timestamp = creation_timestamp + @pod_selector = pod_selector + @policy_types = policy_types + @ingress = ingress + @egress = egress + end + + def self.from_yaml(manifest) + return unless manifest + + policy = YAML.safe_load(manifest, symbolize_names: true) + return if !policy[:metadata] || !policy[:spec] + + metadata = policy[:metadata] + spec = policy[:spec] + self.new( + name: metadata[:name], + namespace: metadata[:namespace], + pod_selector: spec[:podSelector], + policy_types: spec[:policyTypes], + ingress: spec[:ingress], + egress: spec[:egress] + ) + rescue Psych::SyntaxError, Psych::DisallowedClass + nil + end + + def self.from_resource(resource) + return unless resource + return if !resource[:metadata] || !resource[:spec] + + metadata = resource[:metadata] + spec = resource[:spec].to_h + self.new( + name: metadata[:name], + namespace: metadata[:namespace], + creation_timestamp: metadata[:creationTimestamp], + pod_selector: spec[:podSelector], + policy_types: spec[:policyTypes], + ingress: spec[:ingress], + egress: spec[:egress] + ) + end + + def generate + ::Kubeclient::Resource.new.tap do |resource| + resource.metadata = metadata + resource.spec = spec + end + end + + def as_json(opts = nil) + { + name: name, + namespace: namespace, + creation_timestamp: creation_timestamp, + manifest: manifest + } + end + + private + + attr_reader :name, :namespace, :creation_timestamp, :pod_selector, :policy_types, :ingress, :egress + + def metadata + { name: name, namespace: namespace } + end + + def spec + { + podSelector: pod_selector, + policyTypes: policy_types, + ingress: ingress, + egress: egress + } + end + + def manifest + YAML.dump(metadata: metadata, spec: spec) + end + end + end +end diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb index 878665e02e5..e6d17ba691c 100644 --- a/spec/factories/design_management/versions.rb +++ b/spec/factories/design_management/versions.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :design_version, class: 'DesignManagement::Version' do - sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") } + sha issue { designs.first&.issue || create(:issue) } author { issue&.author || create(:user) } diff --git a/spec/factories/git_wiki_commit_details.rb b/spec/factories/git_wiki_commit_details.rb new file mode 100644 index 00000000000..b35f102fd4d --- /dev/null +++ b/spec/factories/git_wiki_commit_details.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :git_wiki_commit_details, class: 'Gitlab::Git::Wiki::CommitDetails' do + skip_create + + transient do + author { create(:user) } + end + + sequence(:message) { |n| "Commit message #{n}" } + + initialize_with { new(author.id, author.username, author.name, author.email, message) } + end +end diff --git a/spec/factories/sequences.rb b/spec/factories/sequences.rb index cdc64a8502e..ca0804965df 100644 --- a/spec/factories/sequences.rb +++ b/spec/factories/sequences.rb @@ -12,4 +12,5 @@ FactoryBot.define do sequence(:branch) { |n| "my-branch-#{n}" } sequence(:past_time) { |n| 4.hours.ago + (2 * n).seconds } sequence(:iid) + sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") } end diff --git a/spec/factories/wiki_pages.rb b/spec/factories/wiki_pages.rb index e5df970dc9c..e7fcc19bbfe 100644 --- a/spec/factories/wiki_pages.rb +++ b/spec/factories/wiki_pages.rb @@ -66,5 +66,6 @@ FactoryBot.define do end sequence(:wiki_page_title) { |n| "Page #{n}" } + sequence(:wiki_filename) { |n| "Page_#{n}.md" } sequence(:sluggified_title) { |n| "slug-#{n}" } end diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js new file mode 100644 index 00000000000..acdfb5139bf --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; + +describe('Edit Form Buttons', () => { + let wrapper; + const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); + + const createComponent = props => { + wrapper = shallowMount(EditFormButtons, { + propsData: { + updateConfidentialAttribute: () => {}, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders Turn On in the ', () => { + createComponent({ + isConfidential: false, + }); + + expect(findConfidentialToggle().text()).toBe('Turn On'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + }); + + expect(findConfidentialToggle().text()).toBe('Turn Off'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js new file mode 100644 index 00000000000..137019a1e1b --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import EditForm from '~/sidebar/components/confidential/edit_form.vue'; + +describe('Edit Form Dropdown', () => { + let wrapper; + const toggleForm = () => {}; + const updateConfidentialAttribute = () => {}; + + const createComponent = props => { + wrapper = shallowMount(EditForm, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders "You are going to turn off the confidentiality." in the ', () => { + createComponent({ + isConfidential: false, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js deleted file mode 100644 index 32da9f83112..00000000000 --- a/spec/frontend/sidebar/confidential_edit_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; - -describe('Edit Form Buttons', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editFormButtons); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on or off text based on confidentiality', () => { - expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true); - - expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true); - }); -}); diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js deleted file mode 100644 index 369088cb258..00000000000 --- a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editForm from '~/sidebar/components/confidential/edit_form.vue'; - -describe('Edit Form Dropdown', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editForm); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on the appropriate warning text', () => { - expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true); - - expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true); - }); -}); diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb index b2dbbbf59ac..8c6c91d919e 100644 --- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb @@ -48,6 +48,7 @@ describe Gitlab::Ci::Config::Entry::Reports do :cobertura | 'cobertura-coverage.xml' :terraform | 'tfplan.json' :accessibility | 'gl-accessibility.json' + :cluster_applications | 'gl-cluster-applications.json' end with_them do diff --git a/spec/lib/gitlab/kubernetes/network_policy_spec.rb b/spec/lib/gitlab/kubernetes/network_policy_spec.rb new file mode 100644 index 00000000000..87ed922e099 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/network_policy_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::NetworkPolicy do + let(:policy) do + described_class.new( + name: name, + namespace: namespace, + creation_timestamp: '2020-04-14T00:08:30Z', + pod_selector: pod_selector, + policy_types: %w(Ingress Egress), + ingress: ingress, + egress: egress + ) + end + + let(:name) { 'example-name' } + let(:namespace) { 'example-namespace' } + let(:pod_selector) { { matchLabels: { role: 'db' } } } + + let(:ingress) do + [ + { + from: [ + { namespaceSelector: { matchLabels: { project: 'myproject' } } } + ] + } + ] + end + + let(:egress) do + [ + { + ports: [{ port: 5978 }] + } + ] + end + + describe '.from_yaml' do + let(:manifest) do + <<-POLICY +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: example-name + namespace: example-namespace +spec: + podSelector: + matchLabels: + role: db + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + project: myproject + POLICY + end + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: name, namespace: namespace }, + spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil } + ) + end + + subject { Gitlab::Kubernetes::NetworkPolicy.from_yaml(manifest)&.generate } + + it { is_expected.to eq(resource) } + + context 'with nil manifest' do + let(:manifest) { nil } + + it { is_expected.to be_nil } + end + + context 'with invalid manifest' do + let(:manifest) { "\tfoo: bar" } + + it { is_expected.to be_nil } + end + + context 'with manifest without metadata' do + let(:manifest) do + <<-POLICY +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +spec: + podSelector: + matchLabels: + role: db + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + project: myproject + POLICY + end + + it { is_expected.to be_nil } + end + + context 'with manifest without spec' do + let(:manifest) do + <<-POLICY +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: example-name + namespace: example-namespace + POLICY + end + + it { is_expected.to be_nil } + end + + context 'with disallowed class' do + let(:manifest) do + <<-POLICY +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: example-name + namespace: example-namespace + creationTimestamp: 2020-04-14T00:08:30Z +spec: + podSelector: + matchLabels: + role: db + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + project: myproject + POLICY + end + + it { is_expected.to be_nil } + end + end + + describe '.from_resource' do + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: name, namespace: namespace, creationTimestamp: '2020-04-14T00:08:30Z', resourceVersion: '4990' }, + spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil } + ) + end + let(:generated_resource) do + ::Kubeclient::Resource.new( + metadata: { name: name, namespace: namespace }, + spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil } + ) + end + + subject { Gitlab::Kubernetes::NetworkPolicy.from_resource(resource)&.generate } + + it { is_expected.to eq(generated_resource) } + + context 'with nil resource' do + let(:resource) { nil } + + it { is_expected.to be_nil } + end + + context 'with resource without metadata' do + let(:resource) do + ::Kubeclient::Resource.new( + spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil } + ) + end + + it { is_expected.to be_nil } + end + + context 'with resource without spec' do + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: name, namespace: namespace, uid: '128cf288-7de4-11ea-aceb-42010a800089', resourceVersion: '4990' } + ) + end + + it { is_expected.to be_nil } + end + end + + describe '#generate' do + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: name, namespace: namespace }, + spec: { podSelector: pod_selector, policyTypes: %w(Ingress Egress), ingress: ingress, egress: egress } + ) + end + + subject { policy.generate } + + it { is_expected.to eq(resource) } + end + + describe '#as_json' do + let(:json_policy) do + { + name: name, + namespace: namespace, + creation_timestamp: '2020-04-14T00:08:30Z', + manifest: YAML.dump( + { + metadata: { name: name, namespace: namespace }, + spec: { podSelector: pod_selector, policyTypes: %w(Ingress Egress), ingress: ingress, egress: egress } + } + ) + } + end + + subject { policy.as_json } + + it { is_expected.to eq(json_policy) } + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 844cd1626ab..5e0c31c3293 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -84,6 +84,21 @@ describe Event do end end + describe 'scopes' do + describe 'created_at' do + it 'can find the right event' do + time = 1.day.ago + event = create(:event, created_at: time) + false_positive = create(:event, created_at: 2.days.ago) + + found = described_class.created_at(time) + + expect(found).to include(event) + expect(found).not_to include(false_positive) + end + end + end + describe "Push event" do let(:project) { create(:project, :private) } let(:user) { project.owner } @@ -511,6 +526,14 @@ describe Event do expect(described_class.not_wiki_page).to match_array(non_wiki_events) end end + + describe '.for_wiki_meta' do + it 'finds events for a given wiki page metadata object' do + event = events.select(&:wiki_page?).first + + expect(described_class.for_wiki_meta(event.target)).to contain_exactly(event) + end + end end describe '#wiki_page and #wiki_page?' do diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb index f9bfc31ba64..0255dd802cf 100644 --- a/spec/models/wiki_page/meta_spec.rb +++ b/spec/models/wiki_page/meta_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe WikiPage::Meta do - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project, :wiki_repo) } let_it_be(:other_project) { create(:project) } describe 'Associations' do @@ -169,8 +169,11 @@ describe WikiPage::Meta do described_class.find_or_create(last_known_slug, wiki_page) end - def create_previous_version(title = old_title, slug = last_known_slug) - create(:wiki_page_meta, title: title, project: project, canonical_slug: slug) + def create_previous_version(title: old_title, slug: last_known_slug, date: wiki_page.version.commit.committed_date) + create(:wiki_page_meta, + title: title, project: project, + created_at: date, updated_at: date, + canonical_slug: slug) end def create_context @@ -198,6 +201,8 @@ describe WikiPage::Meta do title: wiki_page.title, project: wiki_page.wiki.project ) + expect(meta.updated_at).to eq(wiki_page.version.commit.committed_date) + expect(meta.created_at).not_to be_after(meta.updated_at) expect(meta.slugs.where(slug: last_known_slug)).to exist expect(meta.slugs.canonical.where(slug: wiki_page.slug)).to exist end @@ -209,22 +214,32 @@ describe WikiPage::Meta do end end - context 'the slug is too long' do - let(:last_known_slug) { FFaker::Lorem.characters(2050) } + context 'there are problems' do + context 'the slug is too long' do + let(:last_known_slug) { FFaker::Lorem.characters(2050) } - it 'raises an error' do - expect { find_record }.to raise_error ActiveRecord::ValueTooLong + it 'raises an error' do + expect { find_record }.to raise_error ActiveRecord::ValueTooLong + end end - end - context 'a conflicting record exists' do - before do - create(:wiki_page_meta, project: project, canonical_slug: last_known_slug) - create(:wiki_page_meta, project: project, canonical_slug: current_slug) + context 'a conflicting record exists' do + before do + create(:wiki_page_meta, project: project, canonical_slug: last_known_slug) + create(:wiki_page_meta, project: project, canonical_slug: current_slug) + end + + it 'raises an error' do + expect { find_record }.to raise_error(ActiveRecord::RecordInvalid) + end end - it 'raises an error' do - expect { find_record }.to raise_error(ActiveRecord::RecordInvalid) + context 'the wiki page is not valid' do + let(:wiki_page) { build(:wiki_page, project: project, title: nil) } + + it 'raises an error' do + expect { find_record }.to raise_error(described_class::WikiPageInvalid) + end end end @@ -258,6 +273,17 @@ describe WikiPage::Meta do end end + context 'the commit happened a day ago' do + before do + allow(wiki_page.version.commit).to receive(:committed_date).and_return(1.day.ago) + end + + include_examples 'metadata examples' do + # Identical to the base case. + let(:query_limit) { 5 } + end + end + context 'the last_known_slug is the same as the current slug, as on creation' do let(:last_known_slug) { current_slug } @@ -292,6 +318,33 @@ describe WikiPage::Meta do end end + context 'a record exists in the DB, but we need to update timestamps' do + let(:last_known_slug) { current_slug } + let(:old_title) { title } + + before do + create_previous_version(date: 1.week.ago) + end + + include_examples 'metadata examples' do + # We need the query, and the update + # SAVEPOINT active_record_2 + # + # SELECT * FROM wiki_page_meta + # INNER JOIN wiki_page_slugs + # ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id + # WHERE wiki_page_meta.project_id = ? + # AND wiki_page_slugs.canonical = TRUE + # AND wiki_page_slugs.slug = ? + # LIMIT 2 + # + # UPDATE wiki_page_meta SET updated_at = ?date WHERE id = ?id + # + # RELEASE SAVEPOINT active_record_2 + let(:query_limit) { 4 } + end + end + context 'we need to update the slug, but not the title' do let(:old_title) { title } @@ -359,14 +412,14 @@ describe WikiPage::Meta do end context 'we want to change the slug back to a previous version' do - let(:slug_1) { 'foo' } - let(:slug_2) { 'bar' } + let(:slug_1) { generate(:sluggified_title) } + let(:slug_2) { generate(:sluggified_title) } let(:wiki_page) { create(:wiki_page, title: slug_1, project: project) } let(:last_known_slug) { slug_2 } before do - meta = create_previous_version(title, slug_1) + meta = create_previous_version(title: title, slug: slug_1) meta.canonical_slug = slug_2 end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index eb241fa123f..305b67a4262 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -844,6 +844,20 @@ describe WikiPage do end end + describe '#version_commit_timestamp' do + context 'for a new page' do + it 'returns nil' do + expect(new_page.version_commit_timestamp).to be_nil + end + end + + context 'for page that exists' do + it 'returns the timestamp of the commit' do + expect(existing_page.version_commit_timestamp).to eq(existing_page.version.commit.committed_date) + end + end + end + private def get_slugs(page_or_dir) diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 09b76c57715..01b5ce981df 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -34,7 +34,7 @@ describe Ci::RetryBuildService do job_artifacts_container_scanning job_artifacts_dast job_artifacts_license_management job_artifacts_license_scanning job_artifacts_performance job_artifacts_lsif - job_artifacts_terraform + job_artifacts_terraform job_artifacts_cluster_applications job_artifacts_codequality job_artifacts_metrics scheduled_at job_variables waiting_for_resource_at job_artifacts_metrics_referee job_artifacts_network_referee job_artifacts_dotenv diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb index 0a8a4d5bf58..987b4ad68f7 100644 --- a/spec/services/event_create_service_spec.rb +++ b/spec/services/event_create_service_spec.rb @@ -162,16 +162,25 @@ describe EventCreateService do context "The action is #{action}" do let(:event) { service.wiki_event(meta, user, action) } - it 'creates the event' do + it 'creates the event', :aggregate_failures do expect(event).to have_attributes( wiki_page?: true, valid?: true, persisted?: true, action: action, - wiki_page: wiki_page + wiki_page: wiki_page, + author: user ) end + it 'is idempotent', :aggregate_failures do + expect { event }.to change(Event, :count).by(1) + duplicate = nil + expect { duplicate = service.wiki_event(meta, user, action) }.not_to change(Event, :count) + + expect(duplicate).to eq(event) + end + context 'the feature is disabled' do before do stub_feature_flags(wiki_events: false) diff --git a/spec/services/git/wiki_push_service/change_spec.rb b/spec/services/git/wiki_push_service/change_spec.rb new file mode 100644 index 00000000000..547874270ab --- /dev/null +++ b/spec/services/git/wiki_push_service/change_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Git::WikiPushService::Change do + subject { described_class.new(project_wiki, change, raw_change) } + + let(:project_wiki) { double('ProjectWiki') } + let(:raw_change) { double('RawChange', new_path: new_path, old_path: old_path, operation: operation) } + let(:change) { { oldrev: generate(:sha), newrev: generate(:sha) } } + + let(:new_path) do + case operation + when :deleted + nil + else + generate(:wiki_filename) + end + end + + let(:old_path) do + case operation + when :added + nil + when :deleted, :renamed + generate(:wiki_filename) + else + new_path + end + end + + describe '#page' do + context 'the page does not exist' do + before do + expect(project_wiki).to receive(:find_page).with(String, String).and_return(nil) + end + + %i[added deleted renamed modified].each do |op| + context "the operation is #{op}" do + let(:operation) { op } + + it { is_expected.to have_attributes(page: be_nil) } + end + end + end + + context 'the page can be found' do + let(:wiki_page) { double('WikiPage') } + + before do + expect(project_wiki).to receive(:find_page).with(slug, revision).and_return(wiki_page) + end + + context 'the page has been deleted' do + let(:operation) { :deleted } + let(:slug) { old_path.chomp('.md') } + let(:revision) { change[:oldrev] } + + it { is_expected.to have_attributes(page: wiki_page) } + end + + %i[added renamed modified].each do |op| + let(:operation) { op } + let(:slug) { new_path.chomp('.md') } + let(:revision) { change[:newrev] } + + it { is_expected.to have_attributes(page: wiki_page) } + end + end + end + + describe '#last_known_slug' do + context 'the page has been created' do + let(:operation) { :added } + + it { is_expected.to have_attributes(last_known_slug: new_path.chomp('.md')) } + end + + %i[renamed modified deleted].each do |op| + context "the operation is #{op}" do + let(:operation) { op } + + it { is_expected.to have_attributes(last_known_slug: old_path.chomp('.md')) } + end + end + end + + describe '#event_action' do + context 'the page is deleted' do + let(:operation) { :deleted } + + it { is_expected.to have_attributes(event_action: Event::DESTROYED) } + end + + context 'the page is added' do + let(:operation) { :added } + + it { is_expected.to have_attributes(event_action: Event::CREATED) } + end + + %i[renamed modified].each do |op| + context "the page is #{op}" do + let(:operation) { op } + + it { is_expected.to have_attributes(event_action: Event::UPDATED) } + end + end + end +end diff --git a/spec/services/git/wiki_push_service_spec.rb b/spec/services/git/wiki_push_service_spec.rb new file mode 100644 index 00000000000..2f844b92a2a --- /dev/null +++ b/spec/services/git/wiki_push_service_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Git::WikiPushService, services: true do + include RepoHelpers + + let_it_be(:key_id) { create(:key, user: current_user).shell_id } + let_it_be(:project) { create(:project, :wiki_repo) } + let_it_be(:current_user) { create(:user) } + let_it_be(:git_wiki) { project.wiki.wiki } + let_it_be(:repository) { git_wiki.repository } + + describe '#execute' do + context 'the push contains more than the permitted number of changes' do + def run_service + process_changes { described_class::MAX_CHANGES.succ.times { write_new_page } } + end + + it 'creates only MAX_CHANGES events' do + expect { run_service }.to change(Event, :count).by(described_class::MAX_CHANGES) + end + end + + context 'default_branch collides with a tag' do + it 'creates only one event' do + base_sha = current_sha + write_new_page + + service = create_service(base_sha, ['refs/heads/master', 'refs/tags/master']) + + expect { service.execute }.to change(Event, :count).by(1) + end + end + + describe 'successfully creating events' do + let(:count) { Event::WIKI_ACTIONS.size } + + def run_service + wiki_page_a = create(:wiki_page, project: project) + wiki_page_b = create(:wiki_page, project: project) + + process_changes do + write_new_page + update_page(wiki_page_a.title) + delete_page(wiki_page_b.page.path) + end + end + + it 'creates one event for every wiki action' do + expect { run_service }.to change(Event, :count).by(count) + end + + it 'handles all known actions' do + run_service + + expect(Event.last(count).pluck(:action)).to match_array(Event::WIKI_ACTIONS) + end + end + + context 'two pages have been created' do + def run_service + process_changes do + write_new_page + write_new_page + end + end + + it 'creates two events' do + expect { run_service }.to change(Event, :count).by(2) + end + + it 'creates two metadata records' do + expect { run_service }.to change(WikiPage::Meta, :count).by(2) + end + + it 'creates appropriate events' do + run_service + + expect(Event.last(2)).to all(have_attributes(wiki_page?: true, action: Event::CREATED)) + end + end + + context 'a non-page file as been added' do + it 'does not create events, or WikiPage metadata' do + expect do + process_changes { write_non_page } + end.not_to change { [Event.count, WikiPage::Meta.count] } + end + end + + context 'one page, and one non-page have been created' do + def run_service + process_changes do + write_new_page + write_non_page + end + end + + it 'creates a wiki page creation event' do + expect { run_service }.to change(Event, :count).by(1) + + expect(Event.last).to have_attributes(wiki_page?: true, action: Event::CREATED) + end + + it 'creates one metadata record' do + expect { run_service }.to change(WikiPage::Meta, :count).by(1) + end + end + + context 'one page has been added, and then updated' do + def run_service + process_changes do + title = write_new_page + update_page(title) + end + end + + it 'creates just a single event' do + expect { run_service }.to change(Event, :count).by(1) + end + + it 'creates just one metadata record' do + expect { run_service }.to change(WikiPage::Meta, :count).by(1) + end + + it 'creates a new wiki page creation event' do + run_service + + expect(Event.last).to have_attributes( + wiki_page?: true, + action: Event::CREATED + ) + end + end + + context 'when a page we already know about has been updated' do + let(:wiki_page) { create(:wiki_page, project: project) } + + before do + create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) + end + + def run_service + process_changes { update_page(wiki_page.title) } + end + + it 'does not create a new meta-data record' do + expect { run_service }.not_to change(WikiPage::Meta, :count) + end + + it 'creates a new event' do + expect { run_service }.to change(Event, :count).by(1) + end + + it 'adds an update event' do + run_service + + expect(Event.last).to have_attributes( + wiki_page?: true, + action: Event::UPDATED + ) + end + end + + context 'when a page we do not know about has been updated' do + def run_service + wiki_page = create(:wiki_page, project: project) + process_changes { update_page(wiki_page.title) } + end + + it 'creates a new meta-data record' do + expect { run_service }.to change(WikiPage::Meta, :count).by(1) + end + + it 'creates a new event' do + expect { run_service }.to change(Event, :count).by(1) + end + + it 'adds an update event' do + run_service + + expect(Event.last).to have_attributes( + wiki_page?: true, + action: Event::UPDATED + ) + end + end + + context 'when a page we do not know about has been deleted' do + def run_service + wiki_page = create(:wiki_page, project: project) + process_changes { delete_page(wiki_page.page.path) } + end + + it 'create a new meta-data record' do + expect { run_service }.to change(WikiPage::Meta, :count).by(1) + end + + it 'creates a new event' do + expect { run_service }.to change(Event, :count).by(1) + end + + it 'adds an update event' do + run_service + + expect(Event.last).to have_attributes( + wiki_page?: true, + action: Event::DESTROYED + ) + end + end + + it 'calls log_error for every event we cannot create' do + base_sha = current_sha + count = 3 + count.times { write_new_page } + message = 'something went very very wrong' + allow_next_instance_of(WikiPages::EventCreateService, current_user) do |service| + allow(service).to receive(:execute) + .with(String, WikiPage, Integer) + .and_return(ServiceResponse.error(message: message)) + end + + service = create_service(base_sha) + + expect(service).to receive(:log_error).exactly(count).times.with(message) + + service.execute + end + + describe 'feature flags' do + shared_examples 'a no-op push' do + it 'does not create any events' do + expect { process_changes { write_new_page } }.not_to change(Event, :count) + end + + it 'does not even look for events to process' do + base_sha = current_sha + write_new_page + + service = create_service(base_sha) + + expect(service).not_to receive(:changed_files) + + service.execute + end + end + + context 'the wiki_events feature is disabled' do + before do + stub_feature_flags(wiki_events: false) + end + + it_behaves_like 'a no-op push' + end + + context 'the wiki_events_on_git_push feature is disabled' do + before do + stub_feature_flags(wiki_events_on_git_push: false) + end + + it_behaves_like 'a no-op push' + + context 'but is enabled for a given project' do + before do + stub_feature_flags(wiki_events_on_git_push: { enabled: true, thing: project }) + end + + it 'creates events' do + expect { process_changes { write_new_page } }.to change(Event, :count).by(1) + end + end + end + end + end + + # In order to construct the correct GitPostReceive object that represents the + # changes we are applying, we need to describe the changes between old-ref and + # new-ref. Old ref (the base sha) we have to capture before we perform any + # changes. Once the changes have been applied, we can execute the service to + # process them. + def process_changes(&block) + base_sha = current_sha + yield + create_service(base_sha).execute + end + + def create_service(base, refs = ['refs/heads/master']) + changes = post_received(base, refs).changes + described_class.new(project, current_user, changes: changes) + end + + def post_received(base, refs) + change_str = refs.map { |ref| +"#{base} #{current_sha} #{ref}" }.join("\n") + post_received = ::Gitlab::GitPostReceive.new(project, key_id, change_str, {}) + allow(post_received).to receive(:identify).with(key_id).and_return(current_user) + + post_received + end + + def current_sha + repository.gitaly_ref_client.find_branch('master')&.dereferenced_target&.id || Gitlab::Git::BLANK_SHA + end + + # It is important not to re-use the WikiPage services here, since they create + # events - these helper methods below are intended to simulate actions on the repo + # that have not gone through our services. + + def write_new_page + generate(:wiki_page_title).tap { |t| git_wiki.write_page(t, 'markdown', 'Hello', commit_details) } + end + + # We write something to the wiki-repo that is not a page - as, for example, an + # attachment. This will appear as a raw-diff change, but wiki.find_page will + # return nil. + def write_non_page + params = { + file_name: 'attachment.log', + file_content: 'some stuff', + branch_name: 'master' + } + ::Wikis::CreateAttachmentService.new(project, project.owner, params).execute + end + + def update_page(title) + page = git_wiki.page(title: title) + git_wiki.update_page(page.path, title, 'markdown', 'Hey', commit_details) + end + + def delete_page(path) + git_wiki.delete_page(path, commit_details) + end + + def commit_details + create(:git_wiki_commit_details, author: current_user) + end +end diff --git a/spec/services/wiki_pages/event_create_service_spec.rb b/spec/services/wiki_pages/event_create_service_spec.rb new file mode 100644 index 00000000000..cf971b0a02c --- /dev/null +++ b/spec/services/wiki_pages/event_create_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe WikiPages::EventCreateService do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + subject { described_class.new(user) } + + describe '#execute' do + let_it_be(:page) { create(:wiki_page, project: project) } + let(:slug) { generate(:sluggified_title) } + let(:action) { Event::CREATED } + let(:response) { subject.execute(slug, page, action) } + + context 'feature flag is not enabled' do + before do + stub_feature_flags(wiki_events: false) + end + + it 'does not error' do + expect(response).to be_success + .and have_attributes(message: /No event created/) + end + + it 'does not create an event' do + expect { response }.not_to change(Event, :count) + end + end + + context 'the user is nil' do + subject { described_class.new(nil) } + + it 'raises an error on construction' do + expect { subject }.to raise_error ArgumentError + end + end + + context 'the action is illegal' do + let(:action) { Event::WIKI_ACTIONS.max + 1 } + + it 'returns an error' do + expect(response).to be_error + end + + it 'does not create an event' do + expect { response }.not_to change(Event, :count) + end + + it 'does not create a metadata record' do + expect { response }.not_to change(WikiPage::Meta, :count) + end + end + + it 'returns a successful response' do + expect(response).to be_success + end + + context 'the action is a deletion' do + let(:action) { Event::DESTROYED } + + it 'does not synchronize the wiki metadata timestamps with the git commit' do + expect_next_instance_of(WikiPage::Meta) do |instance| + expect(instance).not_to receive(:synch_times_with_page) + end + + response + end + end + + it 'creates a wiki page event' do + expect { response }.to change(Event, :count).by(1) + end + + it 'returns an event in the payload' do + expect(response.payload).to include(event: have_attributes(author: user, wiki_page?: true, action: action)) + end + + it 'records the slug for the page' do + response + meta = WikiPage::Meta.find_or_create(page.slug, page) + + expect(meta.slugs.pluck(:slug)).to include(slug) + end + end +end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 3d24b5f753a..3ad8eced2b3 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -299,6 +299,31 @@ describe PostReceive do end end + context "master" do + let(:default_branch) { 'master' } + let(:oldrev) { '012345' } + let(:newrev) { '6789ab' } + let(:changes) do + <<~EOF + #{oldrev} #{newrev} refs/heads/#{default_branch} + 123456 789012 refs/heads/tést2 + EOF + end + + let(:raw_repo) { double('RawRepo') } + + it 'processes the changes on the master branch' do + expect_next_instance_of(Git::WikiPushService) do |service| + expect(service).to receive(:process_changes).and_call_original + end + expect(project.wiki).to receive(:default_branch).twice.and_return(default_branch) + expect(project.wiki.repository).to receive(:raw).and_return(raw_repo) + expect(raw_repo).to receive(:raw_changes_between).once.with(oldrev, newrev).and_return([]) + + perform + end + end + context "branches" do let(:changes) do <<~EOF @@ -307,6 +332,12 @@ describe PostReceive do EOF end + before do + allow_next_instance_of(Git::WikiPushService) do |service| + allow(service).to receive(:process_changes) + end + end + it 'expires the branches cache' do expect(project.wiki.repository).to receive(:expire_branches_cache).once |