haveapi 0.30.1 → 0.30.2

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,151 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require_relative '../../benchmarks/performance_compare'
6
+ require_relative 'support/samples'
7
+
8
+ describe HaveAPI::Performance do
9
+ let(:root) { File.expand_path('../../../..', __dir__) }
10
+ let(:comparison) { File.join(root, 'servers/ruby/benchmarks/performance_compare.rb') }
11
+
12
+ def with_newer_json
13
+ Dir.mktmpdir('haveapi-extra-json-') do |directory|
14
+ FileUtils.mkdir_p(File.join(directory, 'specifications'))
15
+ library = File.join(directory, 'gems/json-3.1.0/lib')
16
+ FileUtils.mkdir_p(library)
17
+ File.write(File.join(library, 'json.rb'), "module JSON; VERSION = '3.1.0'; end\n")
18
+ spec = Gem::Specification.new do |gem|
19
+ gem.name = 'json'
20
+ gem.version = '3.1.0'
21
+ gem.summary = 'Owned activation control'
22
+ gem.authors = ['Fixture']
23
+ end
24
+ File.write(File.join(directory, 'specifications/json-3.1.0.gemspec'), spec.to_ruby)
25
+ environment = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
26
+ environment.merge!('GEM_HOME' => directory,
27
+ 'GEM_PATH' => [directory, Gem.loaded_specs.fetch('json').base_dir, Gem.default_dir].uniq.join(File::PATH_SEPARATOR))
28
+ yield directory, environment
29
+ end
30
+ end
31
+
32
+ it 'loads the native calibrated version in each public entry despite a newer visible gem' do
33
+ with_newer_json do |_directory, environment|
34
+ [comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
35
+ program = <<~RUBY
36
+ visible = Gem::Specification.find_all_by_name('json').map { |spec| spec.version.to_s }
37
+ require #{entry.inspect}
38
+ puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('json').version.to_s,
39
+ parser: JSON::Ext::Parser.name, parsed: JSON.parse('{"value":42}'))
40
+ RUBY
41
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
42
+ expect(status).to be_success, errors
43
+ result = JSON.parse(output)
44
+ expect(result.fetch('visible')).to include('3.1.0')
45
+ expect(result).to include('loaded' => '2.21.2', 'parser' => 'JSON::Ext::Parser', 'parsed' => { 'value' => 42 })
46
+ end
47
+ end
48
+ end
49
+
50
+ it 'uses the same activation in the neutral worker serialization path' do
51
+ with_newer_json do |_directory, environment|
52
+ program = <<~RUBY
53
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
54
+ HaveAPI::Performance::Worker.define_method(:run) { { json_version: Gem.loaded_specs.fetch('json').version.to_s } }
55
+ exit HaveAPI::Performance::CLI.new(['worker']).execute
56
+ RUBY
57
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program, stdin_data: '{}')
58
+ expect(status).to be_success, errors
59
+ expect(JSON.parse(output)).to include('json_version' => '2.21.2')
60
+ end
61
+ end
62
+
63
+ it 'refuses an already activated incompatible version instead of weakening the environment' do
64
+ with_newer_json do |_directory, environment|
65
+ program = "gem 'json', '= 3.1.0'; require 'json'; require #{comparison.inspect}"
66
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
67
+ expect(status).not_to be_success
68
+ expect(errors).to include('json', '2.21.2', 'already activated json-3.1.0')
69
+ end
70
+ end
71
+
72
+ it 'reports the required version when the calibrated gem is missing' do
73
+ with_newer_json do |directory, environment|
74
+ environment['GEM_PATH'] = directory
75
+ program = "Gem.use_paths(#{directory.inspect}, [#{directory.inspect}]); require #{comparison.inspect}"
76
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
77
+ expect(status).not_to be_success
78
+ expect(errors).to include('json', '2.21.2', '3.1.0')
79
+ expect(errors).to match(/Could not find/)
80
+ end
81
+ end
82
+
83
+ it 'declares the calibrated JSON constraint in all three fresh development bundles' do
84
+ expected = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
85
+ .fetch('environment').fetch('weekly').fetch('gems').fetch('json').fetch('version')
86
+ expect(HaveAPI::Performance::JSON_VERSION).to eq(expected)
87
+ with_newer_json do |directory, environment|
88
+ HaveAPI::Performance::Release::JSON_GEMFILES.each do |path|
89
+ definition = Bundler::Dsl.evaluate(File.join(root, path), nil, {})
90
+ expect(definition.dependencies.select { |dependency| dependency.name == 'json' }.map { |dependency| dependency.requirement.to_s }).to eq(["= #{expected}"])
91
+ end
92
+ # Resolve only this installed constraint in an owned lockless fixture.
93
+ # No repository lock or external dependency is resolved or installed.
94
+ file = File.join(directory, 'Gemfile')
95
+ File.write(file, "source 'https://rubygems.org'\ngem 'json', '= #{expected}'\n")
96
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'lock', '--local', '--gemfile', file)
97
+ expect(status).to be_success, errors
98
+ expect(File.read("#{file}.lock")).to include("json (#{expected})", "json (= #{expected})")
99
+ expect(File.read("#{file}.lock")).not_to include('json (3.1.0)')
100
+ end
101
+ end
102
+
103
+ it 'canonicalizes only the exact known JSON preparation declaration and binds raw replay evidence' do
104
+ release = HaveAPI::Performance::Release
105
+ old = release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] }
106
+ pinned = old.transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
107
+ receipt = release.json_preparation(pinned)
108
+ expect(receipt.fetch('normalized_contents')).to eq(old)
109
+ expect(receipt.fetch('raw_sha256')).not_to eq(receipt.fetch('normalized_sha256'))
110
+ expect(receipt.fetch('declarations').values).to all(eq('exact-development-pin'))
111
+ unversioned = old.transform_values { |text| "#{text} gem 'json'\n" }
112
+ expect(release.json_preparation(unversioned).fetch('normalized_contents')).to eq(old)
113
+ ['3.1.0', '>= 2.21.2'].each do |requirement|
114
+ changed = pinned.transform_values { |text| text.sub('= 2.21.2', requirement) }
115
+ expect { release.json_preparation(changed) }.to raise_error(HaveAPI::Performance::ContractError)
116
+ end
117
+ changed = pinned.merge('Gemfile' => "#{pinned.fetch('Gemfile')}# unrelated comment\ngem 'other'\n")
118
+ expect(release.json_preparation(changed).fetch('normalized_contents')).not_to eq(old)
119
+ inputs = { 'json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256') }
120
+ source = { 'files_sha256' => receipt.fetch('raw_sha256') }
121
+ expect { release.validate_json_preparation(inputs, source) }.not_to raise_error
122
+ %w[raw_sha256 normalized_sha256 declarations].each do |field|
123
+ forged = Marshal.load(Marshal.dump(inputs))
124
+ forged['json_preparation'][field]['Gemfile'] = 'forged'
125
+ expect { release.validate_json_preparation(forged, source) }.to raise_error(HaveAPI::Performance::ContractError)
126
+ end
127
+ end
128
+
129
+ it 'replays the exact preparation delta while rejecting other content and raw source changes' do
130
+ release = HaveAPI::Performance::Release
131
+ %w[exact other-content raw-source].each do |control|
132
+ samples, budgets = PerformanceSamples.fixture
133
+ samples.fetch('observations').each_value do |observation|
134
+ next unless observation.fetch('source').fetch('sha') == samples.fetch('candidate_sha')
135
+
136
+ contents = observation.fetch('inputs').fetch('json_preparation').fetch('contents')
137
+ .transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
138
+ contents['Gemfile'] << "# other content\n" if control == 'other-content'
139
+ receipt = release.json_preparation(contents)
140
+ observation['inputs'].merge!('json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256'))
141
+ samples['sources']['candidate']['files_sha256'].merge!(receipt.fetch('raw_sha256')) unless control == 'raw-source'
142
+ end
143
+ comparator = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budgets), budget_sha256: samples.fetch('budget_sha256'))
144
+ if control == 'exact'
145
+ expect(comparator.compare.fetch('status')).to eq('pass')
146
+ else
147
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,431 @@
1
+ require 'spec_helper'
2
+ require_relative '../../../../utils/performance_ci'
3
+ require_relative '../../benchmarks/endpoints'
4
+ require_relative '../../../../clients/ruby/lib/haveapi/client/version'
5
+ require_relative 'support/samples'
6
+ require_relative '../../test_support/documentation_performance_fixture'
7
+
8
+ describe HaveAPI::Performance::Release do
9
+ let(:root) { File.expand_path('../../../..', __dir__) }
10
+ let(:repository) { HaveAPI::PerformanceCI::Repository.new(git_fixture_root) }
11
+ let(:head) { git_fixture.fetch(:candidate) }
12
+ let(:proof) { repository.release_lineage(head) }
13
+ let(:error) { HaveAPI::Performance::ContractError }
14
+
15
+ # Component CI intentionally has shallow source history and no root bundle.
16
+ # Own the proof graph instead of depending on the checkout's maintenance refs.
17
+ around do |example|
18
+ Dir.mktmpdir('haveapi-release-history-') do |directory|
19
+ example.metadata[:git_fixture_root] = directory
20
+ example.metadata[:git_fixture] = build_history(directory)
21
+ example.run
22
+ end
23
+ end
24
+
25
+ before do
26
+ stub_const('HaveAPI::Performance::REFERENCE', git_fixture.fetch(:reference))
27
+ stub_const('HaveAPI::Performance::Release::MAPPED', git_fixture.fetch(:mapped))
28
+ stub_const('HaveAPI::Performance::Release::METADATA', git_fixture.fetch(:metadata))
29
+ stub_const('HaveAPI::Performance::Release::TREES', git_fixture.fetch(:trees))
30
+ stub_const('HaveAPI::Performance::Release::DELTA', git_fixture.fetch(:delta_sha256))
31
+ end
32
+
33
+ def git_fixture_root
34
+ RSpec.current_example.metadata.fetch(:git_fixture_root)
35
+ end
36
+
37
+ def git_fixture
38
+ RSpec.current_example.metadata.fetch(:git_fixture)
39
+ end
40
+
41
+ def fixture_git(directory, *arguments, input: '')
42
+ env = { 'GIT_AUTHOR_NAME' => 'Fixture', 'GIT_AUTHOR_EMAIL' => 'fixture@example.test',
43
+ 'GIT_COMMITTER_NAME' => 'Fixture', 'GIT_COMMITTER_EMAIL' => 'fixture@example.test',
44
+ 'GIT_AUTHOR_DATE' => '2000-01-01T00:00:00Z', 'GIT_COMMITTER_DATE' => '2000-01-01T00:00:00Z' }
45
+ output, errors, status = Open3.capture3(env, 'git', '-C', directory, *arguments, stdin_data: input)
46
+ raise errors unless status.success?
47
+
48
+ output
49
+ end
50
+
51
+ def fixture_commit(directory, files, parent: nil)
52
+ entries = files.sort.map do |path, body|
53
+ blob = fixture_git(directory, 'hash-object', '-w', '--stdin', input: body).strip
54
+ "100644 blob #{blob}\t#{path}\n"
55
+ end
56
+ tree = fixture_git(directory, 'mktree', input: entries.join).strip
57
+ fixture_git(directory, 'commit-tree', tree, *[('-p' if parent), parent].compact, input: "fixture\n").strip
58
+ end
59
+
60
+ def build_history(directory)
61
+ fixture_git(directory, 'init', '--quiet', '--initial-branch=master')
62
+ original = { 'VERSION' => "0.30.0\n", 'CHANGELOG.md' => "original\n" }
63
+ base = fixture_commit(directory, original)
64
+ reference = fixture_commit(directory, original.merge('engine.rb' => "source\n"), parent: base)
65
+ published = original.merge('VERSION' => "0.30.1\n", 'CHANGELOG.md' => "published 0.30.1\n\noriginal\n")
66
+ metadata = fixture_commit(directory, published, parent: base)
67
+ mapped = fixture_commit(directory, published.merge('engine.rb' => "source\n"), parent: metadata)
68
+ candidate = fixture_commit(directory, published.merge('engine.rb' => "source\n", 'tool.rb' => "candidate\n"), parent: mapped)
69
+ fixture_git(directory, 'update-ref', 'refs/heads/master', reference)
70
+ fixture_git(directory, 'update-ref', 'refs/heads/maintenance', candidate)
71
+ delta = fixture_git(directory, 'diff', '--binary', '--full-index', reference, mapped)
72
+ { reference:, metadata:, mapped:, candidate:,
73
+ trees: [reference, mapped, "#{metadata}^", metadata].map { |sha| fixture_git(directory, 'rev-parse', "#{sha}^{tree}").strip },
74
+ delta_sha256: Digest::SHA256.hexdigest(delta) }
75
+ end
76
+
77
+ def contents(version, profile: 'quick')
78
+ original = described_class.paths(profile).to_h { |path| [path, File.read(File.join(root, path))] }
79
+ own = described_class.version(original)
80
+ original.to_h do |path, value|
81
+ value = "#{version}\n" if path == 'VERSION'
82
+ value = value.sub("VERSION = '#{own}'", "VERSION = '#{version}'") if described_class::VERSIONS.has_key?(path)
83
+ value = value.sub("'haveapi-client', '~> #{own}'", "'haveapi-client', '~> #{version}'") if path.end_with?('.gemspec')
84
+ [path, value]
85
+ end
86
+ end
87
+
88
+ it 'verifies the complete published binary delta and all four exact trees in an owned history' do
89
+ expect(proof).to include('reference_sha' => HaveAPI::Performance::REFERENCE, 'mapped_sha' => described_class::MAPPED,
90
+ 'metadata_sha' => described_class::METADATA, 'trees' => described_class::TREES)
91
+ expect(Digest::SHA256.hexdigest(proof.fetch('reference_delta'))).to eq(described_class::DELTA)
92
+ expect(proof.fetch('reference_delta')).to eq(proof.fetch('metadata_delta'))
93
+ expect(repository.ancestor?(HaveAPI::Performance::REFERENCE, head)).to be(false)
94
+ expect(repository.ancestor?(described_class::MAPPED, head)).to be(true)
95
+ end
96
+
97
+ it 'keeps the proof canonical with abbreviated IDs and changed Git display defaults' do
98
+ [4, 7, 12].each do |abbreviation|
99
+ fixture_git(git_fixture_root, 'config', 'core.abbrev', abbreviation.to_s)
100
+ fixture_git(git_fixture_root, 'config', 'diff.context', '0')
101
+ fixture_git(git_fixture_root, 'config', 'diff.noprefix', 'true')
102
+ fixture_git(git_fixture_root, 'config', 'diff.suppressBlankEmpty', 'true')
103
+ fixture_git(git_fixture_root, 'config', 'diff.external', 'false')
104
+ expect(repository.release_lineage(head)).to eq(proof)
105
+ end
106
+ end
107
+
108
+ it 'accepts ordinary master ancestry in a shallow clone without maintenance objects' do
109
+ Dir.mktmpdir('haveapi-shallow-master-') do |directory|
110
+ fixture_git(directory, 'clone', '--quiet', '--depth=1', '--single-branch', '--branch=master',
111
+ "file://#{git_fixture_root}", '.')
112
+ shallow = HaveAPI::PerformanceCI::Repository.new(directory)
113
+ expect { shallow.git('cat-file', '-e', described_class::MAPPED) }.to raise_error(HaveAPI::PerformanceCI::Error)
114
+ expect(shallow.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
115
+ end
116
+ end
117
+
118
+ it 'rejects modified metadata, reference trees and candidate binding during replay' do
119
+ %w[metadata_delta reference_delta trees candidate_sha].each do |field|
120
+ changed = Marshal.load(Marshal.dump(proof))
121
+ changed[field] = field == 'trees' ? ['f' * 40] * 4 : 'wrong'
122
+ expect { described_class.validate_lineage(changed, head) }.to raise_error(error, /lineage/)
123
+ end
124
+ end
125
+
126
+ it 'does not infer the mapping for nonancestor candidates and fails on missing history' do
127
+ expect(repository.release_lineage(HaveAPI::Performance::REFERENCE)).to be_nil
128
+ expect { repository.release_lineage('f' * 40) }.to raise_error(HaveAPI::PerformanceCI::Error, /ancestry/)
129
+ git = ->(*) { raise HaveAPI::PerformanceCI::Error, 'missing metadata object' }
130
+ expect do
131
+ described_class.lineage(git:, ancestor: ->(left, _right) { left == described_class::MAPPED }, candidate: head)
132
+ end.to raise_error(HaveAPI::PerformanceCI::Error, /missing metadata/)
133
+ end
134
+
135
+ it 'rejects a wrong full tree delta on the live proof path' do
136
+ git = lambda do |*arguments|
137
+ result = repository.git(*arguments)
138
+ arguments.include?('diff') ? "#{result}\n# changed" : result
139
+ end
140
+ expect do
141
+ described_class.lineage(git:, ancestor: repository.method(:ancestor?), candidate: head)
142
+ end.to raise_error(error, /lineage/)
143
+ end
144
+
145
+ it 'uses the same proof in the direct runner and limits nonliteral ancestry to weekly' do
146
+ %w[weekly quick missing wrong-delta].each do |mode|
147
+ profile = mode == 'quick' ? 'quick' : 'weekly'
148
+ cli = HaveAPI::Performance::CLI.new(['run', '--baseline-sha', HaveAPI::Performance::REFERENCE,
149
+ '--candidate-sha', head, '--profile', profile,
150
+ '--baseline-root', root, '--candidate-root', root,
151
+ '--budgets', 'unused', '--output', 'unused'])
152
+ cli.send(:parse)
153
+ allow(cli).to receive(:verify_source) { |role| { root: "/controlled/#{role}", sha: head } }
154
+ allow(cli).to receive(:git) { |*arguments| repository.git(*arguments) }
155
+ allow(cli).to receive(:ancestor?) { |left, right| repository.ancestor?(left, right) }
156
+ if mode == 'missing'
157
+ allow(cli).to receive(:ancestor?).and_return(false)
158
+ elsif mode == 'wrong-delta'
159
+ allow(cli).to receive(:git).with(*described_class::DIFF, HaveAPI::Performance::REFERENCE, described_class::MAPPED).and_return('wrong')
160
+ end
161
+ allow(cli).to receive(:child).and_raise(error, 'unexpected worker')
162
+ if mode == 'weekly'
163
+ allow(cli).to receive(:child).with('baseline', {}, 'pilot').and_raise(error, 'verified pilot boundary')
164
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, 'verified pilot boundary')
165
+ expect(cli).to have_received(:child).with('baseline', {}, 'pilot')
166
+ else
167
+ expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(error, /ancestor|lineage/)
168
+ expect(cli).not_to have_received(:child)
169
+ end
170
+ end
171
+ end
172
+
173
+ it 'normalizes only synchronized own patch identities, preserving every external input' do
174
+ %w[quick weekly].each do |profile|
175
+ old = described_class.capture(contents('0.30.1', profile:))
176
+ new = described_class.capture(contents('0.30.2', profile:))
177
+ expect(old.fetch('raw_sha256')).not_to eq(new.fetch('raw_sha256'))
178
+ expect(old.fetch('normalized_sha256')).to eq(new.fetch('normalized_sha256'))
179
+ expect(new.fetch('own_version')).to eq('0.30.2')
180
+ %w[flake.lock flake.nix .ruby-version Gemfile].each do |path|
181
+ changed = contents('0.30.2', profile:)
182
+ changed[path] += "\n# arbitrary input change\n"
183
+ expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
184
+ end
185
+ %w[required_ruby_version summary activesupport].each do |field|
186
+ changed = contents('0.30.2', profile:)
187
+ changed['servers/ruby/haveapi.gemspec'] = changed.fetch('servers/ruby/haveapi.gemspec').sub(field, "changed_#{field}")
188
+ expect(described_class.capture(changed).fetch('normalized_sha256')).not_to eq(old.fetch('normalized_sha256'))
189
+ end
190
+ end
191
+ end
192
+
193
+ it 'rejects mismatched own constants, package names, constraints and minor versions' do
194
+ [
195
+ ['VERSION', "0.31.2\n"],
196
+ ['VERSION', " 0.30.2\n"],
197
+ ['servers/ruby/lib/haveapi/version.rb', "module HaveAPI\n VERSION = '0.30.3'\nend\n"],
198
+ ['servers/ruby/haveapi.gemspec', "spec.name = 'unrelated'\n"],
199
+ ['servers/ruby/haveapi.gemspec', "spec.name = 'haveapi'\nspec.add_dependency 'haveapi-client', '~> 0.30.3'\n"]
200
+ ].each do |path, value|
201
+ expect { described_class.capture(contents('0.30.2').merge(path => value)) }.to raise_error(error)
202
+ end
203
+ end
204
+
205
+ it 'retains actual worker input receipts and rejects mismatched loaded local components' do
206
+ # Provide owned lock bytes at the input boundary. The production worker still
207
+ # refuses a missing root lock; component-only CI need not install that bundle.
208
+ Dir.mktmpdir('haveapi-release-locks-') do |harness|
209
+ FileUtils.mkdir_p(File.join(harness, 'servers/ruby'))
210
+ File.write(File.join(harness, 'Gemfile.lock'), "controlled tooling lock\n")
211
+ File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
212
+ FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
213
+ File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
214
+ stub_const('HaveAPI::RustClient::VERSION', HaveAPI::VERSION) unless defined?(HaveAPI::RustClient::VERSION)
215
+ stub_const('HaveAPI::Performance::ROOT', harness)
216
+ ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
217
+ preparation = ordinary.fetch('json_preparation')
218
+ expect(preparation.fetch('declarations').values).to all(eq('exact-development-pin'))
219
+ expect(ordinary.fetch('manifests').slice(*described_class::JSON_GEMFILES)).to eq(preparation.fetch('normalized_sha256'))
220
+ expect do
221
+ described_class.validate_json_preparation(ordinary, 'files_sha256' => preparation.fetch('raw_sha256'))
222
+ end.not_to raise_error
223
+ captured = HaveAPI::Performance::Worker.inputs(root, 'quick', release: true)
224
+ expect(captured[:locks]).to have_key('clients/rust/Gemfile.lock')
225
+ expect(captured[:manifests]).to have_key('clients/rust/Gemfile')
226
+ expect(captured.fetch(:release_versions)).to eq(described_class.capture(contents(File.read(File.join(root, 'VERSION')).strip)))
227
+ Dir.mktmpdir('haveapi-release-inputs-') do |source|
228
+ contents('0.30.99').each do |path, value|
229
+ FileUtils.mkdir_p(File.dirname(File.join(source, path)))
230
+ File.write(File.join(source, path), value)
231
+ end
232
+ expect do
233
+ HaveAPI::Performance::Worker.inputs(source, 'quick', release: true)
234
+ end.to raise_error(error, /Wrong loaded local package version/)
235
+ end
236
+ end
237
+ end
238
+
239
+ def framework_footer(version)
240
+ %(<p class="text-right">Generated by <a href="https://github.com/vpsfreecz/haveapi">HaveAPI</a> v#{version}</p>)
241
+ end
242
+
243
+ def served_html
244
+ server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1)
245
+ response = Rack::MockRequest.new(server.app).get('http://example.test/benchmark/api/v1/',
246
+ 'HTTP_HOST' => 'example.test', 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64('admin:fixture-only')}")
247
+ expect(response.status).to eq(200)
248
+ response.body
249
+ end
250
+
251
+ def html_samples(served: nil)
252
+ samples, budget_json = PerformanceSamples.fixture
253
+ samples['release_lineage'] = proof.merge('candidate_sha' => samples.fetch('candidate_sha'))
254
+ normalized = nil
255
+ samples.fetch('sources').each do |role, source|
256
+ receipt = described_class.capture(contents(role == 'baseline' ? '0.30.1' : '0.30.2'))
257
+ source['files_sha256'].merge!(receipt.fetch('raw_sha256'))
258
+ inputs = { 'manifests' => receipt.fetch('normalized_sha256'), 'locks' => samples.fetch('inputs').fetch('locks'),
259
+ 'release_versions' => receipt }
260
+ normalized ||= described_class.graph(inputs)
261
+ samples.fetch('observations').each_value do |observation|
262
+ next unless observation.fetch('source').fetch('root') == source.fetch('root')
263
+
264
+ observation['inputs'] = inputs
265
+ end
266
+ end
267
+ samples['inputs'] = normalized
268
+ id = 'docs.html_resources'
269
+ samples['inventory'].first['id'] = id
270
+ item = samples.fetch('cases').first
271
+ item['id'] = id
272
+ (item.fetch('self_pairs') + item.fetch('pairs')).each do |pair|
273
+ %w[baseline candidate].each do |role|
274
+ sample = pair.fetch(role)
275
+ own = samples.fetch('observations').fetch(sample.fetch('observation')).fetch('inputs').fetch('release_versions').fetch('own_version')
276
+ body = if served
277
+ source_version = File.read(File.join(root, 'VERSION')).strip
278
+ served.gsub("https://webui.haveapi.org/v#{source_version}/#", "https://webui.haveapi.org/v#{own}/#")
279
+ .sub(framework_footer(source_version), framework_footer(own))
280
+ else
281
+ "<p>PUBLIC_SAMPLE_0 init-python-sync init-examples</p><a\n href=\"https://webui.haveapi.org/v#{own}/#http%3A%2F%2Fexample.test\">Connect</a>#{framework_footer(own)}"
282
+ end
283
+ digest = Digest::SHA256.hexdigest(body)
284
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
285
+ sample['outputs'] = [{ 'http_status' => 200, 'envelope_status' => nil, 'body_bytes' => body.bytesize, 'body_sha256' => digest, 'count' => sample.fetch('operations'),
286
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, own)),
287
+ 'comparison' => { 'kind' => 'own-webui-href', 'own_version' => own } }]
288
+ end
289
+ end
290
+ budgets = JSON.parse(budget_json)
291
+ budgets['profiles']['quick'].first['id'] = id
292
+ samples['budget_sha256'] = Digest::SHA256.hexdigest(JSON.generate(budgets))
293
+ [samples, budgets]
294
+ end
295
+
296
+ def compare(samples, budgets)
297
+ HaveAPI::Performance::Comparator.new(samples, budgets, budget_sha256: samples.fetch('budget_sha256')).compare
298
+ end
299
+
300
+ it 'replays distinct raw bodies and input hashes through the narrow normalization' do
301
+ samples, budgets = html_samples
302
+ expect(compare(samples, budgets).fetch('status')).to eq('pass')
303
+ pair = samples.fetch('cases').first.fetch('pairs').first
304
+ expect(pair['baseline']['outputs'].first['body_sha256']).not_to eq(pair['candidate']['outputs'].first['body_sha256'])
305
+ expect(pair['baseline']['outputs'].first['canonical_sha256']).to eq(pair['candidate']['outputs'].first['canonical_sha256'])
306
+ end
307
+
308
+ it 'accepts real served footer/version changes and rejects a forged digest through the replay CLI' do
309
+ samples, budgets = html_samples(served: served_html)
310
+ Dir.mktmpdir('haveapi-release-replay-') do |directory|
311
+ policy = File.join(directory, 'budget.json')
312
+ input = File.join(directory, 'samples.json')
313
+ File.write(policy, JSON.generate(budgets))
314
+ # The CLI runs unchanged, with the same owned mini-history policy as this
315
+ # example. No production defect switch or fixture-history dependency exists.
316
+ fixture = File.join(directory, 'history.json')
317
+ File.write(fixture, JSON.generate(git_fixture))
318
+ adapter = File.join(directory, 'replay.rb')
319
+ File.write(adapter, <<~RUBY)
320
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
321
+ history = JSON.parse(File.read(ARGV.shift))
322
+ performance = HaveAPI::Performance
323
+ performance.send(:remove_const, :REFERENCE)
324
+ performance.const_set(:REFERENCE, history.fetch('reference'))
325
+ { 'MAPPED' => 'mapped', 'METADATA' => 'metadata', 'TREES' => 'trees', 'DELTA' => 'delta_sha256' }.each do |name, key|
326
+ performance::Release.send(:remove_const, name)
327
+ performance::Release.const_set(name, history.fetch(key))
328
+ end
329
+ exit performance::CLI.new(ARGV).execute
330
+ RUBY
331
+ [false, true].each do |tampered|
332
+ samples['cases'].first['pairs'].first['candidate']['outputs'].first['canonical_sha256'] = 'f' * 64 if tampered
333
+ File.write(input, JSON.generate(samples))
334
+ _output, _errors, status = Open3.capture3(RbConfig.ruby, adapter, fixture, 'compare',
335
+ '--input', input, '--budgets', policy,
336
+ '--output', File.join(directory, tampered ? 'tampered' : 'valid'))
337
+ expect(status.exitstatus).to eq(tampered ? 3 : 0)
338
+ end
339
+ end
340
+ end
341
+
342
+ it 'normalizes the actual worker HTML outside timing and retains its raw bytes' do
343
+ own = File.read(File.join(root, 'VERSION')).strip
344
+ body = "<a href=\"https://webui.haveapi.org/v#{own}/#api\">Connect</a>#{framework_footer(own)}"
345
+ scenario = HaveAPI::Performance::DocumentationScenario.allocate
346
+ allow(scenario).to receive(:reset!)
347
+ allow(scenario).to receive_messages(call: Rack::MockResponse.new(200, {}, body),
348
+ validate!: { http_status: 200, envelope_status: nil, body_bytes: body.bytesize,
349
+ body_sha256: Digest::SHA256.hexdigest(body), canonical_sha256: Digest::SHA256.hexdigest(body) },
350
+ counters: { operations: [{ queries: 0, authorizations: 0, executions: 0 }] })
351
+ worker = HaveAPI::Performance::Worker.new('source_root' => root, 'release_versions' => true)
352
+ worker.instance_variable_set(:@current_workload, 'docs.html_resources')
353
+ result = worker.measure(scenario, 1)
354
+ output = result.fetch(:outputs).first
355
+ expect(output.fetch('comparison')).to include('own_version' => File.read(File.join(root, 'VERSION')).strip)
356
+ expect(result.fetch(:bodies).values.first.fetch(:body)).to eq(body)
357
+ expect(output.fetch('canonical_sha256')).to eq(Digest::SHA256.hexdigest(described_class.html(body, output['comparison']['own_version'])))
358
+ end
359
+
360
+ it 'leaves lookalike attributes, other URLs, JSON and example code byte exact' do
361
+ [
362
+ '<a data-href="https://webui.haveapi.org/v0.30.2/#api">',
363
+ '<a href="https://other.test/v0.30.2/#api">',
364
+ '<a href="https://webui.haveapi.org.evil/v0.30.2/#api">',
365
+ '<a href="http://webui.haveapi.org/v0.30.2/#api">',
366
+ '<a href="https://webui.haveapi.org/v0.30.3/#api">',
367
+ '<a href="https://webui.haveapi.org/v0.30.2/other#api">',
368
+ '{"version":"0.30.2","url":"https://webui.haveapi.org/v0.30.2/#api"}',
369
+ '&lt;a href="https://webui.haveapi.org/v0.30.2/#api"&gt;',
370
+ '<code><a href="https://webui.haveapi.org/v0.30.2/#api"></a></code>',
371
+ '<PRE><a href="https://webui.haveapi.org/v0.30.2/#api"></a></PRE>',
372
+ '<script>let x = \'<a href="https://webui.haveapi.org/v0.30.2/#api">\';</script>',
373
+ '<!-- <a href="https://webui.haveapi.org/v0.30.2/#api"> -->'
374
+ ].each do |body|
375
+ expect(described_class.html(body + framework_footer('0.30.2'), '0.30.2')).to eq(body + framework_footer('0.30.PATCH'))
376
+ end
377
+ end
378
+
379
+ it 'requires one exact framework footer matching the verified own version' do
380
+ footer = framework_footer('0.30.2')
381
+ [footer.sub('Generated by', 'Produced by'), footer.sub('github.com', 'other.test'),
382
+ framework_footer('0.30.3'), footer + footer, '<p>No footer</p>',
383
+ "<code>#{footer}</code>", "<!-- #{footer} -->", "<div data-example='#{footer}'></div>"].each do |body|
384
+ expect { described_class.html(body, '0.30.2') }.to raise_error(error, /footer/)
385
+ end
386
+ end
387
+
388
+ it 'preserves footer-like examples, attributes and unrelated version strings' do
389
+ footer = framework_footer('0.30.2')
390
+ opaque = "<pre>#{footer}</pre><code>v0.30.2</code><!-- #{footer} --><div data-example='#{footer}'></div>"
391
+ expect(described_class.html(opaque + footer, '0.30.2')).to eq(opaque + framework_footer('0.30.PATCH'))
392
+ samples, budgets = html_samples(served: served_html)
393
+ sample = samples['cases'].first['pairs'].first['candidate']
394
+ body = "#{sample['bodies'].values.first['body']}<code>v0.30.2</code>"
395
+ digest = Digest::SHA256.hexdigest(body)
396
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
397
+ sample['outputs'].first.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
398
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
399
+ expect { compare(samples, budgets) }.to raise_error(error, /Response identity/)
400
+ end
401
+
402
+ it 'rejects forged raw/normalized hashes, output differences and unverified input contents on replay' do
403
+ %w[raw normalized marker graph raw_graph api auth panels].each do |fault|
404
+ samples, budgets = html_samples
405
+ sample = samples.fetch('cases').first.fetch('pairs').first.fetch('candidate')
406
+ observation = samples.fetch('observations').fetch(sample.fetch('observation'))
407
+ output = sample.fetch('outputs').first
408
+ case fault
409
+ when 'raw' then sample['bodies'].values.first['body'] += 'tampered'
410
+ when 'normalized' then output['canonical_sha256'] = 'f' * 64
411
+ when 'marker' then output['comparison']['own_version'] = '0.30.1'
412
+ when 'graph' then observation['inputs']['release_versions']['normalized_sha256']['Gemfile'] = 'f' * 64
413
+ when 'raw_graph'
414
+ receipt = described_class.capture(observation['inputs']['release_versions']['contents'].merge('Gemfile' => '# replaced'))
415
+ observation['inputs'].merge!('release_versions' => receipt, 'manifests' => receipt['normalized_sha256'])
416
+ else
417
+ raw = sample['bodies'].values.first['body']
418
+ body = case fault
419
+ when 'api' then "#{raw}<pre>{\"version\":\"0.30.2\"}</pre>"
420
+ when 'auth' then "#{raw}ADMIN_ONLY_REQUEST"
421
+ else raw.sub('init-examples', '')
422
+ end
423
+ digest = Digest::SHA256.hexdigest(body)
424
+ sample['bodies'] = { digest => { 'body' => body, 'canonical_body' => nil } }
425
+ output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize,
426
+ 'canonical_sha256' => Digest::SHA256.hexdigest(described_class.html(body, '0.30.2')))
427
+ end
428
+ expect { compare(samples, budgets) }.to raise_error(error), fault
429
+ end
430
+ end
431
+ end