haveapi 0.30.0 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
@@ -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