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 +4 -4
- data/doc/documentation-oauth.md +40 -9
- data/doc/performance.md +9 -2
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_oauth.rb +14 -2
- data/lib/haveapi/version.rb +1 -1
- data/spec/documentation/oauth_spec.rb +152 -6
- data/spec/performance/event_baseline_spec.rb +97 -2
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fef6937fc8c3a3f3ae382a0c6032ae6fcc7900ad9fb6480aed9a9f3c43556c7d
|
|
4
|
+
data.tar.gz: 4339642a36b69c79396e92fcba4acdd3cb13292a63a0afac90b9dfe22c0d4920
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 632a7cb540320f32451ff939e1d6044dd75a8382cd7d32ac0858a1a82533509c1e3935b73904d44f30b3226b4bec337b729d4a870aa4691adcbbfc0ab1c2da9a
|
|
7
|
+
data.tar.gz: ed900045161d35036c3dd5002f395f0ca05e2db1a7d12327c74be483c2adafd2b554cc49313fd3a5ea089c29a9a16069f2d41d009dc3130b7784be4b9c588b42
|
data/doc/documentation-oauth.md
CHANGED
|
@@ -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.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
316
|
-
endpoints fail
|
|
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.
|
|
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
|
|
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),
|
|
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>' \
|
data/lib/haveapi/version.rb
CHANGED
|
@@ -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'),
|
|
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'),
|
|
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 '
|
|
93
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
+
version: 0.32.1
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: i18n
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|