gem_server_conformance 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []