gem_server_conformance 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/gemcutter_utilities"
4
+
5
+ module RequestHelpers
6
+ def self.included(base)
7
+ super
8
+ base.attr_reader :last_response
9
+ end
10
+
11
+ def build_gem(name, version, platform: nil)
12
+ spec = Gem::Specification.new do |s|
13
+ s.name = name
14
+ s.version = version
15
+ s.authors = ["Conformance"]
16
+ s.summary = "Conformance test"
17
+ s.files = []
18
+ s.date = "2024-07-09"
19
+ s.platform = platform if platform
20
+ end
21
+ yield spec if block_given?
22
+
23
+ package = Gem::Package.new(StringIO.new.binmode)
24
+ package.build_time = Time.utc(1970)
25
+ package.spec = spec
26
+ package.setup_signer
27
+ signer = package.instance_variable_get(:@signer)
28
+ package.gem.with_write_io do |gem_io|
29
+ Gem::Package::TarWriter.new gem_io do |gem|
30
+ digests = gem.add_file_signed "metadata.gz", 0o444, signer do |io|
31
+ package.gzip_to io do |gz_io|
32
+ yaml = spec.to_yaml
33
+ yaml.sub!(/^rubygems_version: .*/, "rubygems_version: 3.5.11")
34
+ gz_io.write yaml
35
+ end
36
+ end
37
+ checksums = package.instance_variable_get(:@checksums)
38
+ checksums["metadata.gz"] = digests
39
+
40
+ digests = gem.add_file_signed "data.tar.gz", 0o444, signer do |io|
41
+ package.gzip_to io do |gz_io|
42
+ # no files
43
+ Gem::Package::TarWriter.new gz_io
44
+ end
45
+ end
46
+ checksums["data.tar.gz"] = digests
47
+
48
+ package.add_checksums gem
49
+ end
50
+ end
51
+
52
+ MockGem.new(
53
+ name: name,
54
+ version: spec.version,
55
+ platform: spec.platform,
56
+ sha256: Digest::SHA256.hexdigest(package.gem.io.string),
57
+ contents: package.gem.io.string
58
+ ).tap { @all_gems << _1 }
59
+ end
60
+
61
+ MockGem = Struct.new(:name, :version, :platform, :sha256, :contents, keyword_init: true) do
62
+ def full_name
63
+ if platform == "ruby"
64
+ "#{name}-#{version}"
65
+ else
66
+ "#{name}-#{version}-#{platform}"
67
+ end
68
+ end
69
+
70
+ def prerelease?
71
+ version.prerelease?
72
+ end
73
+
74
+ def size
75
+ contents.bytesize
76
+ end
77
+
78
+ def spec
79
+ io = StringIO.new(contents)
80
+ io.binmode
81
+ Gem::Package.new(io).spec
82
+ end
83
+
84
+ def pretty_print(pp)
85
+ pp.object_address_group(self) do
86
+ attr_names = %i[name version platform sha256 size]
87
+ pp.seplist(attr_names, proc { pp.text "," }) do |attr_name|
88
+ pp.breakable " "
89
+ pp.group(1) do
90
+ pp.text attr_name
91
+ pp.text ":"
92
+ pp.breakable
93
+ value = send(attr_name)
94
+ pp.pp value
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ module Pusher
102
+ extend Gem::GemcutterUtilities
103
+
104
+ def self.options
105
+ {}
106
+ end
107
+ end
108
+
109
+ def push_gem(gem, expected_to:)
110
+ rubygems_api_request(
111
+ :post,
112
+ "api/v1/gems",
113
+ upstream,
114
+ scope: :push_rubygem
115
+ ) do |request|
116
+ request.body = gem.contents
117
+ request["Content-Length"] = gem.size.to_s
118
+ request["Content-Type"] = "application/octet-stream"
119
+ request.add_field "Authorization", Pusher.api_key
120
+ end.tap do
121
+ expect(last_response).to expected_to
122
+ set_time @time + 60
123
+ end
124
+ end
125
+
126
+ def yank_gem(gem, expected_to:)
127
+ rubygems_api_request(
128
+ :delete,
129
+ "api/v1/gems/yank",
130
+ upstream,
131
+ scope: :yank_rubygem
132
+ ) do |request|
133
+ request.body = URI.encode_www_form(
134
+ gem_name: gem.name,
135
+ version: gem.version.to_s,
136
+ platform: gem.platform
137
+ )
138
+ request["Content-Length"] = request.body.bytesize.to_s
139
+ request["Content-Type"] = "application/x-www-form-urlencoded"
140
+ request.add_field "Authorization", Pusher.api_key
141
+ end.tap do
142
+ expect(last_response).to expected_to, last_response.body
143
+ set_time @time + 60
144
+ end
145
+ end
146
+
147
+ def set_time(time) # rubocop:disable Naming/AccessorMethodName
148
+ @time = time
149
+ body = time.iso8601
150
+ rubygems_api_request(
151
+ :post,
152
+ "set_time",
153
+ upstream
154
+ ) do |request|
155
+ request.body = body
156
+ request["Content-Length"] = body.to_s
157
+ request["Content-Type"] = "text/plain"
158
+ end.tap do |response|
159
+ expect(response.code).to eq "200"
160
+ end
161
+ end
162
+
163
+ def rebuild_versions_list
164
+ rubygems_api_request(
165
+ :post,
166
+ "rebuild_versions_list",
167
+ upstream
168
+ ) { nil }.tap do |response|
169
+ expect(response.code).to eq "200"
170
+ set_time @time + 3600
171
+ end
172
+ end
173
+
174
+ def get_versions # rubocop:disable Naming/AccessorMethodName
175
+ rubygems_api_request(
176
+ :get,
177
+ "versions",
178
+ upstream
179
+ ) { nil }
180
+ end
181
+
182
+ def get_names # rubocop:disable Naming/AccessorMethodName
183
+ rubygems_api_request(
184
+ :get,
185
+ "names",
186
+ upstream
187
+ ) { nil }
188
+ end
189
+
190
+ def get_info(name)
191
+ rubygems_api_request(
192
+ :get,
193
+ "info/#{name}",
194
+ upstream
195
+ ) { nil }
196
+ end
197
+
198
+ def get_gem(name)
199
+ rubygems_api_request(
200
+ :get,
201
+ "gems/#{name}.gem",
202
+ upstream
203
+ ) { nil }
204
+ end
205
+
206
+ def get_quick_spec(name)
207
+ rubygems_api_request(
208
+ :get,
209
+ "quick/Marshal.4.8/#{name}.gemspec.rz",
210
+ upstream
211
+ ) { nil }
212
+ end
213
+
214
+ def get_specs(name = nil)
215
+ path = [name, "specs.4.8.gz"].compact.join("_")
216
+ rubygems_api_request(
217
+ :get,
218
+ path,
219
+ upstream
220
+ ) { nil }
221
+ end
222
+
223
+ class MockResponse
224
+ attr_reader :response
225
+
226
+ def initialize(response)
227
+ response.body_encoding = Encoding::BINARY
228
+ @response = response
229
+ end
230
+
231
+ def inspect
232
+ headers = +"HTTP/#{response.http_version} #{response.code} #{response.message}\n".b
233
+ response.each_header do |name, value|
234
+ headers << "#{name}: #{value}\n"
235
+ end
236
+ headers << "\n"
237
+ headers << response.body
238
+ end
239
+
240
+ def ok?
241
+ response.code == "200"
242
+ end
243
+
244
+ def not_found?
245
+ response.code == "404"
246
+ end
247
+
248
+ def conflict?
249
+ response.code == "409"
250
+ end
251
+
252
+ def forbidden?
253
+ response.code == "403"
254
+ end
255
+
256
+ def ==(other)
257
+ return false unless other.is_a?(self.class)
258
+
259
+ response.to_hash == other.response.to_hash &&
260
+ response.body == other.response.body
261
+ end
262
+
263
+ extend Forwardable
264
+
265
+ def_delegators :response, :code, :body, :http_version
266
+ def_delegators "response.body", :match?, :===, :=~
267
+ end
268
+
269
+ def rubygems_api_request(...)
270
+ internal = internal_request?(...)
271
+ Pusher.rubygems_api_request(...).tap do |response|
272
+ @last_response = MockResponse.new(response) unless internal
273
+ end
274
+ end
275
+
276
+ def internal_request?(_, path, *, **)
277
+ case path
278
+ when "rebuild_versions_list", "set_time"
279
+ true
280
+ else
281
+ false
282
+ end
283
+ end
284
+
285
+ RSpec::Matchers.define :have_header do
286
+ match do |response|
287
+ expect(response).to be_a(MockResponse)
288
+
289
+ @header_value = response.response.fetch(expected, nil)
290
+ values_match?(@value, @header_value)
291
+ end
292
+
293
+ failure_message do |_response|
294
+ super() + ", but got: #{description_of(@header_value)}"
295
+ end
296
+
297
+ chain :with_value do |value|
298
+ @value = value
299
+ end
300
+ end
301
+
302
+ RSpec::Matchers.define :have_body do
303
+ match do |response|
304
+ expect(response).to be_a(MockResponse)
305
+ body = response.body.b
306
+ @actual = body
307
+ values_match?(expected, body)
308
+ end
309
+
310
+ diffable
311
+ end
312
+
313
+ RSpec::Matchers.define :encoded_as do
314
+ match do |str|
315
+ @actual = str.encoding.to_s
316
+ expect(str.encoding).to eq(expected)
317
+ end
318
+ diffable
319
+ end
320
+
321
+ RSpec::Matchers.define :be_valid_compact_index_reponse do
322
+ match notify_expectation_failures: true do |response|
323
+ expect(response).to be_a(MockResponse)
324
+ .and be_ok
325
+ .and have_header("content-type").with_value("text/plain; charset=utf-8")
326
+ .and have_header("accept-ranges").with_value("bytes")
327
+ .and have_header("digest").with_value("sha-256=#{Digest::SHA256.base64digest(response.body)}")
328
+ .and have_header("repr-digest").with_value("sha-256=:#{Digest::SHA256.base64digest(response.body)}:")
329
+ .and have_header("etag").with_value("\"#{Digest::MD5.hexdigest(response.body)}\"")
330
+ end
331
+ end
332
+
333
+ RSpec::Matchers.define :have_content_length do
334
+ match do |response|
335
+ expect(response).to be_a(MockResponse)
336
+ expect(response).to have_header("content-length").with_value(response.body.bytesize.to_s)
337
+ end
338
+ end
339
+
340
+ RSpec::Matchers.define :be_unchanged do
341
+ match do |response|
342
+ expect(response).to be_a(MockResponse)
343
+ expect(response).to be_not_found
344
+ end
345
+ end
346
+
347
+ RSpec::Matchers.define :unmarshal_as do
348
+ match do |response|
349
+ expect(response).to be_a(MockResponse)
350
+ body = response.body.b
351
+ body = if must_inflate
352
+ Zlib.inflate(body)
353
+ else
354
+ Zlib.gunzip(body)
355
+ end
356
+ @actual = Marshal.load(body)
357
+ values_match?(expected, @actual)
358
+ end
359
+
360
+ chain :inflate, :must_inflate
361
+
362
+ diffable
363
+ end
364
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepHelpers
4
+ class Step
5
+ attr_reader :parent, :requests, :children, :last_responses, :blk
6
+
7
+ def initialize(ctx, parent = nil, &blk)
8
+ @ctx = ctx
9
+ @parent = parent
10
+ @requests = []
11
+ @last_responses = {}
12
+ @children = []
13
+ @parent&.children&.<< self
14
+ @blk = blk
15
+ define! unless parent
16
+ end
17
+
18
+ def previous_requests
19
+ return [] unless @parent
20
+
21
+ req = @parent.requests + @parent.previous_requests
22
+ req.uniq!
23
+
24
+ req.reject! do |m, a, _k, _|
25
+ requests.any? { |m_2, a_2, _, _| m == m_2 && a == a_2 }
26
+ end
27
+ req
28
+ end
29
+
30
+ def then(message, before: nil, **kwargs, &blk)
31
+ raise ArgumentError, "block required" unless blk
32
+ raise ArgumentError, "message required" unless message
33
+ raise "already has children" if @children.any?
34
+
35
+ Step.new(@ctx.instance_eval { context(message, **kwargs) }, self).tap do |step|
36
+ step.instance_variable_get(:@ctx).before(:all, &before) if before
37
+ step.instance_eval(&blk)
38
+ step.define!
39
+ end
40
+ end
41
+
42
+ # class NullReporter
43
+ # def self.method_missing(...)
44
+ # pp(...)
45
+ # end
46
+
47
+ # def self.example_failed(ex)
48
+ # pp ex
49
+ # puts ex.display_exception.full_message
50
+ # end
51
+ # end
52
+
53
+ def request(method, *args, **kwargs, &blk)
54
+ name = method.to_s
55
+ name += "(#{args.map(&:inspect).join(", ")})" unless args.empty?
56
+ name += " unchanged" if kwargs[:unchanged]
57
+ step = self
58
+
59
+ reset_examples = proc do |ctx|
60
+ ctx.examples.each do |example|
61
+ example.display_exception = nil
62
+ example.metadata[:execution_result] = RSpec::Core::Example::ExecutionResult.new
63
+ end
64
+ ctx.children.each(&reset_examples)
65
+ end
66
+
67
+ @ctx.context "#{name} response", **kwargs do
68
+ before(:all) do
69
+ 20.times do
70
+ send(method, *args)
71
+ step.last_responses[[method, *args]] = last_response
72
+
73
+ reporter = RSpec::Core::NullReporter
74
+ self.class.store_before_context_ivars(self)
75
+ result_for_this_group = self.class.run_examples(reporter)
76
+ results_for_descendants = self.class.ordering_strategy.order(self.class.children).map do |child|
77
+ child.run(reporter)
78
+ end.all?
79
+
80
+ reset_examples[self.class]
81
+ break if result_for_this_group && results_for_descendants
82
+
83
+ sleep 0.1
84
+ end
85
+ end
86
+
87
+ let(:parent_response) do
88
+ step.parent.last_responses[[method, *args]]
89
+ end
90
+
91
+ alias_method :subject, :last_response
92
+ instance_eval(&blk)
93
+ end
94
+
95
+ @requests << [method, args, kwargs, blk, self]
96
+ end
97
+
98
+ def pushed_gem(full_name)
99
+ request(:get_gem, full_name) do
100
+ let(:gem) { @all_gems.reverse_each.find { _1.full_name == full_name } || raise("gem not found") }
101
+ it { is_expected.to be_ok }
102
+
103
+ it { is_expected.to have_header("content-type").with_value("application/octet-stream") }
104
+ .metadata[:content_type_header] = true
105
+ it { is_expected.to have_content_length }
106
+ .metadata[:content_length_header] = true
107
+ it { is_expected.to have_body(eq(gem.contents)) }
108
+ end
109
+
110
+ request(:get_quick_spec, full_name) do
111
+ let(:gem) { @all_gems.reverse_each.find { _1.full_name == full_name } || raise("gem not found") }
112
+
113
+ it { is_expected.to be_ok }
114
+
115
+ it { is_expected.to have_header("content-type").with_value("application/octet-stream") }
116
+ .metadata[:content_type_header] = true
117
+ it { is_expected.to have_content_length }
118
+ .metadata[:content_length_header] = true
119
+ it { is_expected.to unmarshal_as(gem.spec.tap(&:sanitize).tap(&:abbreviate)).inflate(true) }
120
+ end
121
+ end
122
+
123
+ def yanked_gem(full_name)
124
+ request(:get_gem, full_name) do
125
+ let(:gem) { @all_gems.reverse_each.find { _1.full_name == full_name } || raise("gem not found") }
126
+
127
+ it { is_expected.to be_not_found.or be_forbidden }
128
+ it { is_expected.not_to have_body(including(gem.contents)) }
129
+ end
130
+
131
+ request(:get_quick_spec, full_name) do
132
+ let(:gem) { @all_gems.reverse_each.find { _1.full_name == full_name } || raise("gem not found") }
133
+
134
+ it { is_expected.to be_not_found.or be_forbidden }
135
+ end
136
+ end
137
+
138
+ def define!
139
+ step = self
140
+
141
+ instance_eval(&step.blk) if step.blk
142
+
143
+ if step.parent.nil?
144
+ @ctx.instance_eval do
145
+ attr_reader :upstream
146
+
147
+ before(:all) do
148
+ @upstream = ENV.fetch("UPSTREAM", nil)
149
+ unless upstream
150
+ Bundler.with_original_env do
151
+ @upstream = "http://localhost:4567"
152
+ @pid = spawn("ruby", "-rbundler/setup", "lib/gem_server_conformance/server.rb", out: "/dev/null",
153
+ err: "/dev/null")
154
+ sleep 1
155
+ end
156
+ end
157
+
158
+ @all_gems = []
159
+ set_time Time.utc(1990)
160
+ end
161
+
162
+ after(:all) do
163
+ if @pid
164
+ Process.kill "TERM", @pid
165
+ Process.wait @pid
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ step.previous_requests.each do |method, args, kwargs, blk, s|
172
+ request(method, *args, unchanged: true, **kwargs) do
173
+ let(:parent_response) do
174
+ s.parent.last_responses[[method, *args]]
175
+ end
176
+ instance_eval(&blk)
177
+ if kwargs[:compact_index]
178
+ it "is expected to have the same etag" do
179
+ is_expected.to have_header("ETag").with_value(step.parent.last_responses[[method,
180
+ *args]].response["ETag"])
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ if requests.any?
187
+ @ctx.context "/versions", compact_index: true do
188
+ it "has matching etags" do
189
+ expect(step.last_responses).to include([:get_versions])
190
+ versions_response = step.last_responses[[:get_versions]]
191
+
192
+ expected = versions_response.body.lines.to_h do |l|
193
+ n, _, e = l.split
194
+ [n, "\"#{e}\""]
195
+ end
196
+ expected.delete("---")
197
+ expected.delete("created_at:")
198
+
199
+ etags = step.last_responses.filter_map do |k, v|
200
+ next unless k.first == :get_info
201
+ next unless v.ok?
202
+
203
+ [k[1], v.response["ETag"]]
204
+ end.to_h
205
+
206
+ expect(etags).to eq(expected)
207
+ end
208
+ end
209
+
210
+ @ctx.context "all expected requests", compact_index: true do
211
+ it "are tested" do
212
+ expect(step.last_responses).to include([:get_versions])
213
+ versions_response = step.last_responses[[:get_versions]]
214
+
215
+ expected = versions_response.body.lines.flat_map do |l|
216
+ next if l.start_with?("---")
217
+ next if l.start_with?("created_at:")
218
+
219
+ n, versions, = l.split
220
+ versions.split(",").flat_map do |v|
221
+ v.delete_prefix!("-")
222
+ [
223
+ [:get_quick_spec, ["#{n}-#{v}"]],
224
+ [:get_gem, ["#{n}-#{v}"]]
225
+ ]
226
+ end << [:get_info, [n]]
227
+ end
228
+ expected.compact!
229
+ expected.uniq!
230
+
231
+ actual = step.requests.map { |m, a, _| [m, a] }
232
+
233
+ missing = expected - actual
234
+
235
+ expect(missing).to be_empty
236
+ end
237
+ end
238
+ end
239
+
240
+ children.each(&:define!)
241
+ end
242
+ end
243
+
244
+ module ClassMethods
245
+ def all_requests
246
+ @all_requests ||= []
247
+ end
248
+
249
+ def all_requests_indices
250
+ @all_requests_indices ||= {}
251
+ end
252
+ end
253
+
254
+ def self.included(base)
255
+ base.extend(ClassMethods)
256
+ end
257
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gem_server_conformance
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Samuel Giddins
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-07-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ description:
28
+ email:
29
+ - segiddins@rubycentral.org
30
+ executables:
31
+ - gem_server_conformance
32
+ - gem_server_example
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".rspec"
37
+ - ".rubocop.yml"
38
+ - ".ruby-version"
39
+ - CHANGELOG.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - exe/gem_server_conformance
43
+ - exe/gem_server_example
44
+ - lib/gem_server_conformance/server.rb
45
+ - lib/gem_server_conformance/version.rb
46
+ - spec/gem_server_conformance_spec.rb
47
+ - spec/spec_helper.rb
48
+ - spec/support/request_helpers.rb
49
+ - spec/support/step_helpers.rb
50
+ homepage: https://github.com/rubygems/gem_server_conformance
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/rubygems/gem_server_conformance
55
+ rubygems_mfa_required: 'true'
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.0.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.11
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: A conformance test suite for RubyGems servers.
75
+ test_files: []