summaryrefslogtreecommitdiff
path: root/lib/gitlab/utils/delegator_override/validator.rb
blob: 4449fa758773c463bf4ad7075133b99e527f13c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# frozen_string_literal: true

module Gitlab
  module Utils
    module DelegatorOverride
      class Validator
        UnexpectedDelegatorOverrideError = Class.new(StandardError)

        attr_reader :delegator_class, :target_classes

        OVERRIDE_ERROR_MESSAGE = <<~EOS
          We've detected that the delegator is overriding a specific method(s) on the target class.
          Please make sure if it's intentional and handle this error accordingly.
          See https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/presenters/README.md#validate-accidental-overrides for more information.
        EOS

        def initialize(delegator_class)
          @delegator_class = delegator_class
          @target_classes = []
        end

        def add_allowlist(names)
          allowed_method_names.concat(names)
        end

        def allowed_method_names
          @allowed_method_names ||= []
        end

        def add_target(target_class)
          return unless target_class

          @target_classes << target_class

          # Also include all descendants inheriting from the target,
          # to make sure we catch methods that are only defined in some of them.
          @target_classes += target_class.descendants
        end

        # This will make sure allowlist we put into ancestors are all included
        def expand_on_ancestors(validators)
          delegator_class.ancestors.each do |ancestor|
            next if delegator_class == ancestor # ancestor includes itself

            validator_ancestor = validators[ancestor]

            next unless validator_ancestor

            add_allowlist(validator_ancestor.allowed_method_names)
          end
        end

        def validate_overrides!
          return if target_classes.empty?

          errors = []

          # Workaround to fully load the instance methods in the target class.
          # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69823#note_678887402
          begin
            target_classes.map(&:new)
          rescue ArgumentError
            # Some models might raise ArgumentError here, but it's fine in this case,
            # because this is enough to force ActiveRecord to generate the methods we
            # need to verify, so it's safe to ignore it.
          end

          (delegator_class.instance_methods - allowlist).each do |method_name|
            target_classes.each do |target_class|
              next unless target_class.instance_methods.include?(method_name)

              errors << generate_error(method_name, target_class, delegator_class)
            end
          end

          return if errors.empty?

          details = errors.map { |error| "- #{error}" }.join("\n")

          raise UnexpectedDelegatorOverrideError,
            <<~TEXT
              #{OVERRIDE_ERROR_MESSAGE}
              Here are the conflict details.

              #{details}
            TEXT
        end

        private

        def generate_error(method_name, target_class, delegator_class)
          target_location = extract_location(target_class, method_name)
          delegator_location = extract_location(delegator_class, method_name)
          Error.new(method_name, target_class, target_location, delegator_class, delegator_location)
        end

        def extract_location(klass, method_name)
          klass.instance_method(method_name).source_location&.join(':') || 'unknown'
        end

        def allowlist
          [].tap do |allowed|
            allowed.concat(allowed_method_names)
            allowed.concat(Object.instance_methods)
            allowed.concat(::Delegator.instance_methods)
          end
        end
      end
    end
  end
end