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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +730 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +579 -0
- data/doc/client-examples.md +233 -24
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_clients.rb +158 -35
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/server.rb +5 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/client_examples/dynamic_js_spec.rb +1 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +202 -32
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/documentation/client_examples_spec.rb +10 -0
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +342 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +279 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +431 -0
- data/spec/performance/runner_spec.rb +476 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- 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
|
+
'<a href="https://webui.haveapi.org/v0.30.2/#api">',
|
|
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
|