haveapi 0.32.0 → 0.32.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 543db42b7a86f64548fb4b06cc9b7aec1fedaad147de9814a43860f4a2333e14
4
- data.tar.gz: 4cdf30eb588b0ab495391fe5bc93f630e272b5f3a1f73187c16a293030e5de00
3
+ metadata.gz: fef6937fc8c3a3f3ae382a0c6032ae6fcc7900ad9fb6480aed9a9f3c43556c7d
4
+ data.tar.gz: 4339642a36b69c79396e92fcba4acdd3cb13292a63a0afac90b9dfe22c0d4920
5
5
  SHA512:
6
- metadata.gz: 835dda07aaea82261a29912c7d03270811053dcb16113b2d80b388b8a4c24b848438a9cd7c7ac6866867940501e5bfe35abf62f39eb34f6a66a47fc14c4376a2
7
- data.tar.gz: 359f066b2dae8e27c3a2918606cd7aafe5b671e9098d379ee98fb8761df069a49335cd0966cfad715d4a94267e6c01b300e5e213e20f297fd2bcb22735926096
6
+ metadata.gz: 632a7cb540320f32451ff939e1d6044dd75a8382cd7d32ac0858a1a82533509c1e3935b73904d44f30b3226b4bec337b729d4a870aa4691adcbbfc0ab1c2da9a
7
+ data.tar.gz: ed900045161d35036c3dd5002f395f0ca05e2db1a7d12327c74be483c2adafd2b554cc49313fd3a5ea089c29a9a16069f2d41d009dc3130b7784be4b9c588b42
@@ -41,10 +41,35 @@ token expiry. No refresh token is stored or used.
41
41
 
42
42
  `switch_user` defaults to `false` and accepts only `true` or `false`. Enable it
43
43
  only after verifying that a fresh authorization for this dedicated client lets
44
- the reader enter different credentials. For vpsAdmin, this requires a dedicated
45
- nondefault client with `allow_single_sign_on=false`. This option asserts that
46
- the operator has checked that behavior; it does not discover account-selection
47
- support or send `prompt` or `select_account` parameters.
44
+ the reader enter different credentials. This option asserts that the operator
45
+ has checked that behavior; it does not discover account-selection support or
46
+ send `prompt` or `select_account` parameters.
47
+
48
+ `switch_user_revoke_params` defaults to `{}` and accepts a static hash of string
49
+ keys and string values. These fields are added to the revoke POST only for a
50
+ validated Switch user request. They are never sent during ordinary Sign out,
51
+ authorization or token exchange. The required `client_id`, `client_secret`,
52
+ `token` and `token_type_hint` fields cannot be configured through this option.
53
+ The server copies and freezes the hash and its strings at startup; browser
54
+ fields and later changes to the original hash cannot change the revoke payload.
55
+ The option has no effect when `switch_user` is `false`.
56
+
57
+ For example, a dedicated nondefault vpsAdmin client can allow SSO and use the
58
+ provider's `close_sso` revoke parameter when switching:
59
+
60
+ ```ruby
61
+ api.documentation_oauth(
62
+ # Include the client, callback, session and scope options shown above.
63
+ switch_user: true,
64
+ switch_user_revoke_params: { 'close_sso' => '1' }
65
+ )
66
+ ```
67
+
68
+ Verify that the provider accepts these parameters and that the next authorization
69
+ allows different credentials. Revoking the documentation token alone can leave
70
+ SSO available when another active authorization shares it. Provider-specific
71
+ SSO closure need not revoke that other authorization's access token. A dedicated
72
+ client with SSO disabled is another way to permit account switching.
48
73
 
49
74
  The callback must be an absolute HTTPS URL with the exact mounted callback path,
50
75
  without credentials, query or fragment. The server takes authorize, token and
@@ -145,13 +170,19 @@ state and PKCE values; the old pending attempt and session are cleared. No retur
145
170
  URL or scope supplied by the switch request changes that destination. Cancelling
146
171
  the new authorization leaves the old documentation session signed out. A revoke
147
172
  failure retains the 502 response described above and does not continue to login.
148
- Switching does not sign out of the provider's whole-account SSO session.
173
+ Switching can request provider-specific SSO behavior through the configured
174
+ revoke parameters; HaveAPI does not interpret them or guarantee whole-account
175
+ sign-out. Ordinary Sign out still revokes only the documentation token without
176
+ those parameters.
149
177
 
150
178
  To disable this feature, remove its optional configuration before starting older
151
- documentation code. Existing API data needs no migration or rollback. Cookies
152
- expire or are ignored. Disable or revoke the dedicated OAuth client through
153
- normal provider administration when needed; documentation rollback does not
154
- delete clients automatically. Without this configuration, documentation remains
179
+ documentation code. In particular, remove `switch_user_revoke_params` before
180
+ rolling back to a HaveAPI version that does not support it; the unknown option
181
+ would prevent startup. Upgrade HaveAPI before enabling the option, including on
182
+ each worker that loads the configuration. Existing API data needs no migration
183
+ or rollback. Cookies expire or are ignored. Disable or revoke the dedicated
184
+ OAuth client through normal provider administration when needed; documentation
185
+ rollback does not delete clients automatically. Without this configuration, documentation remains
155
186
  anonymous and offers no authentication controls; ordinary API authentication is
156
187
  unchanged. Older documentation code may use API credentials and legacy browser
157
188
  sign-in routes again. Rolling back requires no cookie or database migration.
data/doc/performance.md CHANGED
@@ -312,8 +312,15 @@ Missing event/source evidence fails even when no relevant path can be establishe
312
312
  The immutable accepted reference is
313
313
  `b85ec364057f536940d689b09e612512ffffaf82`. Required ancestry is checked. The
314
314
  reference and budgets never move automatically. Deleted branches, malformed or
315
- missing commits, unavailable new-branch fallback, partial manual pairs and equal
316
- endpoints fail. PRs use the head commit, never the synthetic merge commit. An
315
+ missing commits, unavailable new-branch fallback and partial manual pairs fail.
316
+ Equal endpoints also fail, except for a validated quick branch push with a zero
317
+ `before` whose merge-base with the captured default equals the candidate. This
318
+ includes branches created at the default head or an older commit already
319
+ contained in it. The resolver retains the exact candidate, baseline and
320
+ captured-default SHAs, an explanatory reason and empty changed paths, and sets
321
+ `relevant: false`. The workflow records `no-work` without running benchmarks or
322
+ reporting a measured performance result. The campaign runner still requires
323
+ distinct sources. PRs use the head commit, never the synthetic merge commit. An
317
324
  existing branch push uses `before`, never `HEAD^` or a guessed fallback. A quick
318
325
  push can compare a nonancestor before/after pair after a force push; its exact
319
326
  event pair is carried through all ancestry guards. Direct CLI use of that
data/haveapi.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.32.0'
18
+ s.add_dependency 'haveapi-client', '~> 0.32.1'
19
19
  s.add_dependency 'i18n', '>= 1.6', '< 2'
20
20
  s.add_dependency 'json'
21
21
  s.add_dependency 'mail'
@@ -19,7 +19,8 @@ module HaveAPI
19
19
  COOKIE_LIMIT = 3800
20
20
  RESPONSE_LIMIT = 16_384
21
21
  PENDING_LIFETIME = 600
22
- FIELDS = %i[client_id client_secret redirect_uri session_secret scope session_lifetime switch_user].freeze
22
+ FIELDS = %i[client_id client_secret redirect_uri session_secret scope session_lifetime
23
+ switch_user switch_user_revoke_params].freeze
23
24
 
24
25
  class Transport
25
26
  def post(uri, parameters)
@@ -93,6 +94,15 @@ module HaveAPI
93
94
  raise Invalid, 'Invalid documentation OAuth switch-user capability'
94
95
  end
95
96
 
97
+ revoke_params = options.fetch(:switch_user_revoke_params, {})
98
+ unless revoke_params.is_a?(Hash) && revoke_params.all? do |name, value|
99
+ name.is_a?(String) && value.is_a?(String) && !%w[client_id client_secret token token_type_hint].include?(name)
100
+ end
101
+ raise Invalid, 'Invalid documentation OAuth switch-user revoke parameters'
102
+ end
103
+
104
+ @switch_user_revoke_params = revoke_params.to_h { |name, value| [name.dup.freeze, value.dup.freeze] }.freeze
105
+
96
106
  @origin = "#{@redirect_uri.scheme}://#{@redirect_uri.host}"
97
107
  @origin += ":#{@redirect_uri.port}" unless @redirect_uri.port == 443
98
108
  key = ActiveSupport::KeyGenerator.new(secret, iterations: 65_536)
@@ -222,8 +232,10 @@ module HaveAPI
222
232
  end
223
233
  clear_cookie(handler, 'session')
224
234
  clear_cookie(handler, 'pending')
235
+ parameters = credentials.merge('token' => session.fetch('token'), 'token_type_hint' => 'access_token')
236
+ parameters.merge!(@switch_user_revoke_params) if switch_user
225
237
  begin
226
- @transport.post(@endpoints.fetch(:revoke), credentials.merge('token' => session.fetch('token'), 'token_type_hint' => 'access_token'))
238
+ @transport.post(@endpoints.fetch(:revoke), parameters)
227
239
  rescue Failure
228
240
  handler.halt(502, { 'content-type' => 'text/html; charset=utf-8' },
229
241
  '<p>Local sign-out succeeded, but the server session may remain until it expires.</p>' \
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.32.0'.freeze
3
+ VERSION = '0.32.1'.freeze
4
4
  end
@@ -31,6 +31,27 @@ module DocumentationOAuthSpec
31
31
  User.new('basic-reader') if username == 'basic-reader' && password == 'basic-password'
32
32
  end
33
33
  end
34
+
35
+ class SharedSSOProvider
36
+ def initialize
37
+ @authorizations = %w[PRIVATE_ACCESS_TOKEN OTHER_ACCESS_TOKEN]
38
+ @sso_active = true
39
+ end
40
+
41
+ def revoke(parameters)
42
+ @authorizations.delete(parameters.fetch('token'))
43
+ @sso_active = false if @authorizations.empty? || parameters['end_shared_session'] == '1'
44
+ ''
45
+ end
46
+
47
+ def valid_token?(token)
48
+ @authorizations.include?(token)
49
+ end
50
+
51
+ def fresh_authorization_identity
52
+ 'admin' if @sso_active
53
+ end
54
+ end
34
55
  end
35
56
 
36
57
  describe HaveAPI::DocumentationOAuth do
@@ -243,7 +264,7 @@ describe HaveAPI::DocumentationOAuth do
243
264
  end
244
265
 
245
266
  context 'with valid session CSRF and a switch-capable client' do
246
- let(:oauth_options) { super().merge(switch_user: true) }
267
+ let(:oauth_options) { super().merge(switch_user: true, switch_user_revoke_params: { 'end_shared_session' => '1' }) }
247
268
 
248
269
  [nil, 'null', 'https://foreign.example', 'http://docs.example', 'https://docs.example:444'].each do |origin|
249
270
  [false, true].each do |switch_user|
@@ -269,7 +290,10 @@ describe HaveAPI::DocumentationOAuth do
269
290
  expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
270
291
  expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
271
292
  expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
272
- expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
293
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
294
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
295
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
296
+ 'end_shared_session' => '1')
273
297
  end
274
298
  end
275
299
 
@@ -668,8 +692,122 @@ describe HaveAPI::DocumentationOAuth do
668
692
  expect { described_class.new(**oauth_options, switch_user: false) }.not_to raise_error
669
693
  end
670
694
 
695
+ it 'requires static string keys and values for switch-user revoke parameters' do
696
+ [nil, [], 'PRIVATE_VALUE', { end_shared_session: 'PRIVATE_VALUE' }, { 'extra' => nil },
697
+ { 'extra' => true }, { 'extra' => 1 }, { 'extra' => [] }, { 'extra' => { 'nested' => 'PRIVATE_VALUE' } }].each do |value|
698
+ expect { described_class.new(**oauth_options, switch_user_revoke_params: value) }
699
+ .to raise_error(described_class::Invalid, 'Invalid documentation OAuth switch-user revoke parameters')
700
+ end
701
+ end
702
+
703
+ %w[client_id client_secret token token_type_hint].each do |field|
704
+ it "rejects overriding required revoke field #{field} in configuration" do
705
+ expect { described_class.new(**oauth_options, switch_user_revoke_params: { field => 'PRIVATE_VALUE' }) }
706
+ .to raise_error(described_class::Invalid, 'Invalid documentation OAuth switch-user revoke parameters')
707
+ end
708
+ end
709
+
710
+ it 'keeps configured switch-user revoke parameters inert when switching is disabled' do
711
+ oauth_options[:switch_user_revoke_params] = { 'end_shared_session' => '1' }
712
+ sign_in
713
+ controls = capture_controls
714
+ csrf = session_csrf
715
+ expect(controls.last).not_to have_key(:switch_user)
716
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
717
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'))
718
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
719
+ expect(last_response.status).to eq(303)
720
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
721
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
722
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token')
723
+ end
724
+
725
+ [false, true].each do |configured|
726
+ context "with shared SSO and #{configured ? 'configured' : 'default'} switch-user revoke parameters" do
727
+ let(:oauth_options) do
728
+ super().merge(switch_user: true).tap do |options|
729
+ options[:switch_user_revoke_params] = { 'end_shared_session' => '1' } if configured
730
+ end
731
+ end
732
+
733
+ it 'revokes only the documentation token and lets the provider decide whether fresh authorization reuses SSO' do
734
+ provider = DocumentationOAuthSpec::SharedSSOProvider.new
735
+ allow(transport).to receive(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), anything) do |_, parameters|
736
+ provider.revoke(parameters)
737
+ end
738
+ sign_in
739
+ csrf = session_csrf
740
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
741
+ expect(last_response.status).to eq(303)
742
+ fields = { 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
743
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token' }
744
+ fields['end_shared_session'] = '1' if configured
745
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), fields)
746
+ expect(provider.valid_token?('PRIVATE_ACCESS_TOKEN')).to be(false)
747
+ expect(provider.valid_token?('OTHER_ACCESS_TOKEN')).to be(true)
748
+ get last_response.location
749
+ expect(last_response.status).to eq(302)
750
+ expect(provider.fresh_authorization_identity).to eq(configured ? nil : 'admin')
751
+ end
752
+ end
753
+ end
754
+
671
755
  context 'with user switching enabled' do
672
- let(:oauth_options) { super().merge(switch_user: true) }
756
+ let(:oauth_options) { super().merge(switch_user: true, switch_user_revoke_params: { 'end_shared_session' => '1' }) }
757
+
758
+ it 'requires an authenticated documentation session before switching' do
759
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: 'x' * 43, switch_user: '1' },
760
+ 'HTTP_ORIGIN' => 'https://docs.example'
761
+ expect(last_response.status).to eq(403)
762
+ expect(cookie_headers).to be_empty
763
+ expect(transport).not_to have_received(:post)
764
+ end
765
+
766
+ it 'requires the configured HTTPS origin before switching' do
767
+ sign_in
768
+ csrf = session_csrf
769
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
770
+ %w[http://docs.example https://foreign.example].each do |origin|
771
+ post "#{origin}/api/_documentation/oauth/logout", { csrf:, switch_user: '1' },
772
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}"
773
+ expect(last_response.status).to eq(400)
774
+ expect(cookie_headers).to be_empty
775
+ end
776
+ expect(transport).to have_received(:post).once
777
+ end
778
+
779
+ it 'copies configured revoke fields before the caller mutates its hash, keys or values' do
780
+ key = +'end_shared_session'
781
+ value = +'1'
782
+ parameters = {}.compare_by_identity
783
+ parameters[key] = value
784
+ oauth_options[:switch_user_revoke_params] = parameters
785
+ app
786
+ key.replace('changed')
787
+ value.replace('changed')
788
+ parameters.clear
789
+ parameters['token'] = 'BROWSER_TOKEN'
790
+ sign_in
791
+ csrf = session_csrf
792
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
793
+ expect(last_response.status).to eq(303)
794
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
795
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
796
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
797
+ 'end_shared_session' => '1')
798
+ end
799
+
800
+ it 'sends the original revoke form on ordinary sign-out even if the browser supplies extra fields' do
801
+ sign_in
802
+ csrf = session_csrf
803
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, end_shared_session: '1', token: 'BROWSER_TOKEN' },
804
+ 'HTTP_ORIGIN' => 'https://docs.example'
805
+ expect(last_response.status).to eq(303)
806
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
807
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
808
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
809
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token')
810
+ end
673
811
 
674
812
  it 'offers switching only after validated documentation-cookie authentication' do
675
813
  controls = capture_controls
@@ -688,21 +826,29 @@ describe HaveAPI::DocumentationOAuth do
688
826
 
689
827
  it 'revokes and clears the old session before starting fresh state and PKCE at the fixed login' do
690
828
  original = sign_in
829
+ expect(original).not_to have_key('end_shared_session')
830
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/token'),
831
+ hash_excluding('end_shared_session'))
691
832
  csrf = session_csrf
692
833
  post 'https://docs.example/api/_documentation/oauth/logout',
693
- { csrf:, switch_user: '1', version: '2', return: 'https://foreign.example/', scope: 'other' },
834
+ { csrf:, switch_user: '1', version: '2', return: 'https://foreign.example/', scope: 'other',
835
+ client_id: 'BROWSER_CLIENT', client_secret: 'BROWSER_SECRET', token: 'BROWSER_TOKEN',
836
+ token_type_hint: 'refresh_token', end_shared_session: '0', unexpected: 'BROWSER_EXTRA' },
694
837
  'HTTP_ORIGIN' => 'https://docs.example'
695
838
  expect(last_response.status).to eq(303)
696
839
  expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
697
840
  expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
698
- expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
841
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
842
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
843
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
844
+ 'end_shared_session' => '1')
699
845
  get last_response.location
700
846
  expect(last_response.status).to eq(302)
701
847
  fresh = URI.decode_www_form(URI(last_response.location).query).to_h
702
848
  expect(fresh).to include('scope' => 'all', 'code_challenge_method' => 'S256')
703
849
  expect(fresh['state']).not_to eq(original['state'])
704
850
  expect(fresh['code_challenge']).not_to eq(original['code_challenge'])
705
- expect(fresh.keys & %w[prompt select_account]).to be_empty
851
+ expect(fresh.keys & %w[prompt select_account end_shared_session]).to be_empty
706
852
  get 'https://docs.example/api/_documentation/oauth/callback', error: 'access_denied', state: fresh.fetch('state')
707
853
  expect(last_response.status).to eq(400)
708
854
  DocumentationOAuthSpec::Config.lookups.clear
@@ -81,6 +81,22 @@ RSpec.describe HaveAPI::PerformanceCI do
81
81
  expect(result[:push_pair]).to be_nil
82
82
  expect(repository).to have_received(:paths).with(base, candidate)
83
83
  end
84
+
85
+ it "selects no work for an equal new-branch merge-base with created=#{created}" do
86
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
87
+ 'before' => '0' * 40, 'created' => created)
88
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
89
+ allow(repository).to receive(:paths).with(candidate, candidate).and_return([])
90
+
91
+ expect(resolve('push', event)).to include(schema_version: 1, event_name: 'push', profile: 'quick',
92
+ baseline_sha: candidate, candidate_sha: candidate, captured_default_sha: default,
93
+ relevant: false, changed_paths: [], push_pair: nil,
94
+ reason: /candidate already contained in captured default, no source changes/)
95
+ expect(repository).to have_received(:commit!).with(default)
96
+ expect(repository).to have_received(:commit!).with(candidate)
97
+ expect(repository).to have_received(:release_lineage).with(candidate).at_least(:once)
98
+ expect(repository).to have_received(:ancestor?).with(candidate, candidate)
99
+ end
84
100
  end
85
101
 
86
102
  it 'does not guess a fallback when the captured default commit is unavailable' do
@@ -89,10 +105,58 @@ RSpec.describe HaveAPI::PerformanceCI do
89
105
  expect { resolve('push', event, default_sha: default) }.to raise_error(described_class::Error, /unavailable default/)
90
106
  end
91
107
 
92
- it 'rejects a new branch whose captured-default merge-base equals its head' do
93
- event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
108
+ it 'resolves default and ancestral branch creation without work using real Git history' do
109
+ Dir.mktmpdir('haveapi-new-branch-history-') do |directory|
110
+ git = lambda do |*arguments, input: ''|
111
+ env = { 'GIT_AUTHOR_NAME' => 'Fixture', 'GIT_AUTHOR_EMAIL' => 'fixture@example.test',
112
+ 'GIT_COMMITTER_NAME' => 'Fixture', 'GIT_COMMITTER_EMAIL' => 'fixture@example.test' }
113
+ output, error, status = Open3.capture3(env, 'git', '-C', directory, *arguments, stdin_data: input)
114
+ raise error unless status.success?
115
+
116
+ output.strip
117
+ end
118
+ git.call('init', '--quiet', '--initial-branch=master')
119
+ commit = lambda do |body, parent = nil|
120
+ blob = git.call('hash-object', '-w', '--stdin', input: body)
121
+ tree = git.call('mktree', input: "100644 blob #{blob}\tGemfile\n")
122
+ git.call('commit-tree', tree, *[('-p' if parent), parent].compact, input: "fixture\n")
123
+ end
124
+ older = commit.call('older')
125
+ captured = commit.call('default', older)
126
+ changed = commit.call('changed', captured)
127
+ git.call('update-ref', 'refs/remotes/origin/master', captured)
128
+ stub_const('HaveAPI::Performance::REFERENCE', older)
129
+ real_resolver = described_class::Resolver.new(described_class::Repository.new(directory))
130
+ event.merge!('ref' => 'refs/heads/haveapi-0.32', 'before' => '0' * 40, 'created' => true)
131
+
132
+ [captured, older, changed].each do |head|
133
+ result = real_resolver.resolve(event_name: 'push', event: event.merge('after' => head), event_sha: head)
134
+ expect(result).to include(candidate_sha: head, captured_default_sha: captured, release_lineage: nil, push_pair: nil,
135
+ baseline_sha: head == changed ? captured : head,
136
+ relevant: head == changed, changed_paths: head == changed ? ['Gemfile'] : [])
137
+ end
138
+ end
139
+ end
140
+
141
+ it 'retains lineage and ancestry rejection for an equal new-branch merge-base' do
142
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'after' => candidate, 'before' => '0' * 40)
143
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
144
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
145
+ event['ref'] = 'refs/heads/feature'
146
+ allow(repository).to receive(:ancestor?).with(candidate, candidate).and_return(false)
147
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /ancestor/)
148
+ end
149
+
150
+ it 'rejects equal endpoints for PRs and fixed-reference profiles' do
151
+ event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
94
152
  allow(repository).to receive(:merge_base).with(candidate, default).and_return(candidate)
153
+ expect { resolve('pull_request', event) }.to raise_error(described_class::Error, /differ/)
154
+ event.merge!('ref' => 'refs/heads/feature', 'after' => described_class::REFERENCE, 'before' => '0' * 40,
155
+ 'inputs' => { 'profile' => 'weekly' })
156
+ allow(repository).to receive(:merge_base).with(described_class::REFERENCE, default).and_return(described_class::REFERENCE)
95
157
  expect { resolve('push', event) }.to raise_error(described_class::Error, /differ/)
158
+ expect { resolver.resolve(event_name: 'schedule', event:, event_sha: described_class::REFERENCE) }
159
+ .to raise_error(described_class::Error, /differ/)
96
160
  end
97
161
 
98
162
  it 'does not replace an unavailable new-branch merge-base with another commit' do
@@ -323,6 +387,37 @@ RSpec.describe HaveAPI::PerformanceCI do
323
387
  end
324
388
  end
325
389
 
390
+ it 'records no-work without requiring benchmark artifacts or the client contract' do
391
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
392
+ steps = YAML.load_file(path).fetch('jobs').fetch('performance').fetch('steps')
393
+ comparison = steps.find { |step| step['name'] == 'Run the selected pair outside Bundler' }
394
+ expect(comparison.fetch('if')).to eq("steps.resolve.outputs.relevant == 'true'")
395
+ completion = steps.find { |step| step['id'] == 'completion' }
396
+ Dir.mktmpdir('haveapi-no-work-gate-') do |directory|
397
+ File.write(File.join(directory, 'selection.json'), JSON.generate(relevant: false))
398
+ output = File.join(directory, 'output')
399
+ environment = { 'RELEVANT' => 'false', 'CLIENT_CONTRACT' => '',
400
+ 'PERFORMANCE_EVIDENCE' => directory, 'GITHUB_OUTPUT' => output }
401
+ _output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
402
+ expect(status.success?).to be(true)
403
+ expect(File.read(output)).to eq("outcome=no-work\n")
404
+ File.unlink(File.join(directory, 'selection.json'))
405
+ _output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
406
+ expect(status.success?).to be(false)
407
+ end
408
+ end
409
+
410
+ it 'keeps equal endpoints out of the campaign even if marked relevant' do
411
+ Dir.mktmpdir('haveapi-no-work-campaign-') do |directory|
412
+ selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick', 'event_name' => 'push',
413
+ 'candidate_sha' => candidate, 'baseline_sha' => candidate, 'push_pair' => nil }
414
+ campaign = described_class::Campaign.new(repository:, selection:,
415
+ evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
416
+ expect { campaign.run }.to raise_error(described_class::Error, /ancestry/)
417
+ expect(File).not_to exist(File.join(directory, 'sources'))
418
+ end
419
+ end
420
+
326
421
  it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
327
422
  path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
328
423
  script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haveapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.32.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.32.0
32
+ version: 0.32.1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.32.0
39
+ version: 0.32.1
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: i18n
42
42
  requirement: !ruby/object:Gem::Requirement