haveapi 0.31.1 → 0.32.0

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +399 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +912 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. metadata +52 -3
@@ -0,0 +1,912 @@
1
+ require 'spec_helper'
2
+ require 'rack/test'
3
+
4
+ module DocumentationOAuthSpec
5
+ User = Struct.new(:login)
6
+
7
+ class Config < HaveAPI::Authentication::OAuth2::Config
8
+ class << self
9
+ attr_accessor :users, :lookups, :requests
10
+ end
11
+
12
+ def base_url
13
+ 'https://issuer.example'
14
+ end
15
+
16
+ def find_user_by_access_token(request, token)
17
+ self.class.lookups << [@version, token]
18
+ self.class.requests << { authorization: request.env['HTTP_AUTHORIZATION'],
19
+ custom: request.env['HTTP_X_HAVEAPI_OAUTH2_TOKEN'],
20
+ query: request.query_string, parameters: request.params.dup }
21
+ self.class.users[[@version, token]]
22
+ end
23
+ end
24
+
25
+ Provider = HaveAPI::Authentication::OAuth2.with_config(Config)
26
+
27
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
28
+ protected
29
+
30
+ def find_user(_request, username, password)
31
+ User.new('basic-reader') if username == 'basic-reader' && password == 'basic-password'
32
+ end
33
+ end
34
+ end
35
+
36
+ describe HaveAPI::DocumentationOAuth do
37
+ api do
38
+ define_resource(:Public) do
39
+ version 1
40
+ auth false
41
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
42
+ output(:hash) { string :value }
43
+ authorize { allow }
44
+ end
45
+ end
46
+ define_resource(:Secret) do
47
+ version 1
48
+ desc 'ROLE_FILTERED_RESOURCE'
49
+ auth true
50
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
51
+ output(:hash) { string :value }
52
+ authorize { |user| user&.login == 'admin' ? allow : deny }
53
+ def exec
54
+ { value: 'private' }
55
+ end
56
+ end
57
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
58
+ input(:hash) { string :value }
59
+ output(:hash) { string :value }
60
+ authorize { |user| user&.login == 'admin' ? allow : deny }
61
+ end
62
+ end
63
+ end
64
+
65
+ let(:oauth_options) do
66
+ { client_id: 'docs-client', client_secret: 'CLIENT_SECRET_NOT_FOR_HTML', session_secret: 's' * 64,
67
+ redirect_uri: 'https://docs.example/api/_documentation/oauth/callback', scope: ['all'], session_lifetime: 1800 }
68
+ end
69
+ let(:transport) { instance_double(described_class::Transport) }
70
+ let(:token_reply) { JSON.generate(access_token: 'PRIVATE_ACCESS_TOKEN', token_type: 'Bearer', expires_in: 3600, refresh_token: 'IGNORED_REFRESH') }
71
+ let(:post_authenticated) { [] }
72
+ let(:api_server) { build_api_server }
73
+
74
+ def build_api_server(versions: [1, 2], basic: false)
75
+ server = HaveAPI::Server.new(self.class::ApiModule)
76
+ server.use_version(versions)
77
+ server.default_version = 1
78
+ server.auth_chain << DocumentationOAuthSpec::BasicProvider if basic
79
+ server.auth_chain << DocumentationOAuthSpec::Provider
80
+ server.documentation_oauth(**oauth_options)
81
+ server.connect_hook(:post_authenticated) do |ret, user|
82
+ post_authenticated << user&.login
83
+ ret
84
+ end
85
+ server.mount('/api/')
86
+ server
87
+ end
88
+
89
+ before do
90
+ DocumentationOAuthSpec::Config.users = { [1, 'PRIVATE_ACCESS_TOKEN'] => DocumentationOAuthSpec::User.new('admin') }
91
+ DocumentationOAuthSpec::Config.lookups = []
92
+ DocumentationOAuthSpec::Config.requests = []
93
+ allow(described_class::Transport).to receive(:new).and_return(transport)
94
+ allow(transport).to receive(:post).and_return(token_reply)
95
+ end
96
+
97
+ def app
98
+ api_server.app
99
+ end
100
+
101
+ def secret_action_path(name)
102
+ actions = api_server.routes.fetch(1).fetch(:resources).fetch(self.class::ApiModule::Secret).fetch(:actions)
103
+ actions.find { |action, _| action.name.split('::').last.downcase == name.to_s }.last
104
+ end
105
+
106
+ def begin_login(query = {})
107
+ get 'https://docs.example/api/_documentation/oauth/login', { version: '1', resource: 'secret', fragment: 'root-secret-index' }.merge(query)
108
+ expect(last_response.status).to eq(302)
109
+ URI.decode_www_form(URI(last_response.location).query).to_h
110
+ end
111
+
112
+ def sign_in
113
+ authorization = begin_login
114
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
115
+ expect(last_response.status).to eq(303)
116
+ authorization
117
+ end
118
+
119
+ def cookie_headers
120
+ Array(last_response.headers['set-cookie']).join("\n")
121
+ end
122
+
123
+ def capture_controls
124
+ controls = []
125
+ allow(api_server.documentation_oauth).to receive(:controls).and_wrap_original do |method, *args|
126
+ result = method.call(*args)
127
+ controls << result
128
+ result
129
+ end
130
+ controls
131
+ end
132
+
133
+ def session_csrf
134
+ get 'https://docs.example/api/v1/'
135
+ last_response.body[/name="csrf" value="([^"]+)"/, 1].tap { |value| expect(value).not_to be_nil }
136
+ end
137
+
138
+ def expect_switch_refused(cookie, query, body, origin: 'https://docs.example', headers: {})
139
+ name, value = cookie
140
+ post "https://docs.example/api/_documentation/oauth/logout#{query}", body,
141
+ { 'HTTP_ORIGIN' => origin, 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' }.merge(headers)
142
+ expect(last_response.status).to eq(403)
143
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
144
+ expect(cookie_headers).to be_empty
145
+ expect(rack_mock_session.cookie_jar.to_hash[name]).to eq(value)
146
+ expect(transport).to have_received(:post).once
147
+ end
148
+
149
+ def expect_existing_session_after_failed_callback(cookie)
150
+ name, value = cookie
151
+ expect(last_response.status).to eq(400)
152
+ expect(last_response.body).to include('Documentation sign-in failed', 'Try again')
153
+ expect(cookie_headers).to include(name.sub(/_session\z/, '_pending'), 'max-age=0')
154
+ expect(cookie_headers).not_to include("#{name}=")
155
+ expect(rack_mock_session.cookie_jar.to_hash[name]).to eq(value)
156
+ expect(rack_mock_session.cookie_jar.to_hash[name.sub(/_session\z/, '_pending')].to_s).to be_empty
157
+
158
+ get 'https://docs.example/api/v1/?resource=secret'
159
+ expect(last_response.status).to eq(200)
160
+ expect(last_response.body).to include('<span class="docs-identity">admin</span>')
161
+ expect(DocumentationOAuthSpec::Config.lookups.last).to eq([1, 'PRIVATE_ACCESS_TOKEN'])
162
+ expect(post_authenticated.last).to eq('admin')
163
+ end
164
+
165
+ it 'uses fixed issuer URLs, PKCE and encrypted scoped cookies, then restores a clean destination' do
166
+ authorization = sign_in
167
+ expect(authorization).to include('client_id' => 'docs-client', 'code_challenge_method' => 'S256', 'scope' => 'all')
168
+ expect(transport).to have_received(:post) do |uri, parameters|
169
+ expect(uri.to_s).to eq('https://issuer.example/api/_auth/oauth2/token')
170
+ verifier = parameters.fetch('code_verifier')
171
+ expect(authorization['code_challenge']).to eq(Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false))
172
+ expect(parameters).to include('client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret])
173
+ end
174
+ expect(last_response.location).to eq('https://docs.example/api/v1/?resource=secret#root-secret-index')
175
+ expect(cookie_headers).to include('secure', 'httponly', 'samesite=lax', 'path=/api/')
176
+ expect(cookie_headers).not_to include('PRIVATE_ACCESS_TOKEN', oauth_options[:client_secret], 'IGNORED_REFRESH', 'domain=')
177
+ get 'https://docs.example/api/v1/?resource=secret'
178
+ expect(last_response.status).to eq(200)
179
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE')
180
+ expect(last_response.body).not_to include('PRIVATE_ACCESS_TOKEN', oauth_options[:client_secret], 'IGNORED_REFRESH')
181
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'strict-origin')
182
+ expect(post_authenticated).to include('admin')
183
+ end
184
+
185
+ context 'with a single-version index containing account controls' do
186
+ let(:api_server) { build_api_server(versions: [1]) }
187
+
188
+ [false, true].each do |signed_in|
189
+ it "uses strict-origin on documentation GET and HEAD responses when signed #{signed_in ? 'in' : 'out'}" do
190
+ sign_in if signed_in
191
+ %w[/api/ /api/v1/ /api/v1/?resource=secret /api/v1/?page=authentication].each do |path|
192
+ %i[get head].each do |method|
193
+ public_send(method, "https://docs.example#{path}")
194
+ expect(last_response.status).to eq(200)
195
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'strict-origin')
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ it 'keeps no-referrer on login, callback and sign-out redirects and failures' do
203
+ authorization = begin_login
204
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
205
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
206
+ expect(last_response.status).to eq(303)
207
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
208
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'unsolicited', state: 'x' * 43
209
+ expect(last_response.status).to eq(400)
210
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
211
+ get 'https://docs.example/api/_documentation/oauth/login', page: 'arbitrary'
212
+ expect(last_response.status).to eq(400)
213
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
214
+ csrf = session_csrf
215
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: 'wrong' }, 'HTTP_ORIGIN' => 'https://docs.example'
216
+ expect(last_response.status).to eq(403)
217
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
218
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
219
+ expect(last_response.status).to eq(303)
220
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
221
+ end
222
+
223
+ %w[application/x-www-form-urlencoded multipart/form-data].each do |encoding|
224
+ it "accepts same-origin sign-out with session CSRF using #{encoding}" do
225
+ sign_in
226
+ csrf = session_csrf
227
+ if encoding == 'multipart/form-data'
228
+ boundary = 'documentation-logout-boundary'
229
+ body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"csrf\"\r\n\r\n#{csrf}\r\n--#{boundary}--\r\n"
230
+ content_type = "#{encoding}; boundary=#{boundary}"
231
+ else
232
+ body = URI.encode_www_form(csrf:)
233
+ content_type = encoding
234
+ end
235
+ post 'https://docs.example/api/_documentation/oauth/logout', body,
236
+ 'HTTP_ORIGIN' => 'https://docs.example', 'CONTENT_TYPE' => content_type
237
+ expect(last_response.status).to eq(303)
238
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
239
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
240
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
241
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
242
+ end
243
+ end
244
+
245
+ context 'with valid session CSRF and a switch-capable client' do
246
+ let(:oauth_options) { super().merge(switch_user: true) }
247
+
248
+ [nil, 'null', 'https://foreign.example', 'http://docs.example', 'https://docs.example:444'].each do |origin|
249
+ [false, true].each do |switch_user|
250
+ it "rejects #{switch_user ? 'switching' : 'sign-out'} from #{origin || 'an absent Origin'} without sign-out effects" do
251
+ sign_in
252
+ csrf = session_csrf
253
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
254
+ fields = { csrf: }
255
+ fields[:switch_user] = '1' if switch_user
256
+ expect_switch_refused(cookie, '', URI.encode_www_form(fields), origin:)
257
+ get 'https://docs.example/api/v1/?resource=secret'
258
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE', '<span class="docs-identity">admin</span>')
259
+ end
260
+ end
261
+ end
262
+
263
+ it 'accepts a native URL-encoded switch with the exact origin and session CSRF' do
264
+ sign_in
265
+ csrf = session_csrf
266
+ post 'https://docs.example/api/_documentation/oauth/logout', URI.encode_www_form(csrf:, switch_user: '1'),
267
+ 'HTTP_ORIGIN' => 'https://docs.example', 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
268
+ expect(last_response.status).to eq(303)
269
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
270
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
271
+ 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'))
273
+ end
274
+ end
275
+
276
+ it 'consumes pending state and rejects replay without another token request' do
277
+ authorization = sign_in
278
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
279
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
280
+ expect_existing_session_after_failed_callback(cookie)
281
+ expect(transport).to have_received(:post).once
282
+ end
283
+
284
+ context 'with an existing authenticated documentation session' do
285
+ def sign_in_cookie
286
+ sign_in
287
+ rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
288
+ end
289
+
290
+ it 'preserves the session after an unsolicited callback without pending state' do
291
+ cookie = sign_in_cookie
292
+ get 'https://docs.example/api/_documentation/oauth/callback'
293
+ expect_existing_session_after_failed_callback(cookie)
294
+ expect(transport).to have_received(:post).once
295
+ end
296
+
297
+ { 'missing state' => { code: 'code' },
298
+ 'mismatched state' => { code: 'code', state: 'x' * 43 },
299
+ 'malformed state' => { code: 'code', 'state[]' => 'bad' } }.each do |failure, query|
300
+ it "preserves the session after #{failure} and consumes pending state" do
301
+ cookie = sign_in_cookie
302
+ begin_login
303
+ get 'https://docs.example/api/_documentation/oauth/callback', query
304
+ expect_existing_session_after_failed_callback(cookie)
305
+ expect(transport).to have_received(:post).once
306
+ end
307
+ end
308
+
309
+ it 'preserves the session after cancellation with the expected state' do
310
+ cookie = sign_in_cookie
311
+ authorization = begin_login
312
+ get 'https://docs.example/api/_documentation/oauth/callback', error: 'access_denied', state: authorization.fetch('state')
313
+ expect_existing_session_after_failed_callback(cookie)
314
+ expect(transport).to have_received(:post).once
315
+ end
316
+
317
+ it 'preserves the session after a callback with the expected state but no code' do
318
+ cookie = sign_in_cookie
319
+ authorization = begin_login
320
+ get 'https://docs.example/api/_documentation/oauth/callback', state: authorization.fetch('state')
321
+ expect_existing_session_after_failed_callback(cookie)
322
+ expect(transport).to have_received(:post).once
323
+ end
324
+
325
+ it 'preserves the session after duplicate callback fields' do
326
+ cookie = sign_in_cookie
327
+ authorization = begin_login
328
+ state = authorization.fetch('state')
329
+ get "https://docs.example/api/_documentation/oauth/callback?code=code&state=#{state}&state=#{state}"
330
+ expect_existing_session_after_failed_callback(cookie)
331
+ expect(transport).to have_received(:post).once
332
+ end
333
+
334
+ it 'preserves the session when pending state expires' do
335
+ cookie = sign_in_cookie
336
+ authorization = begin_login
337
+ allow(Time).to receive(:now).and_return(Time.now + 601)
338
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
339
+ expect_existing_session_after_failed_callback(cookie)
340
+ expect(transport).to have_received(:post).once
341
+ end
342
+
343
+ it 'preserves the session when token exchange fails' do
344
+ cookie = sign_in_cookie
345
+ authorization = begin_login
346
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_PROVIDER_ERROR')
347
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
348
+ expect(last_response.body).not_to include('PRIVATE_PROVIDER_ERROR')
349
+ expect_existing_session_after_failed_callback(cookie)
350
+ expect(transport).to have_received(:post).twice
351
+ end
352
+
353
+ it 'preserves the session when token exchange returns an invalid reply' do
354
+ cookie = sign_in_cookie
355
+ authorization = begin_login
356
+ allow(transport).to receive(:post).and_return('{invalid JSON')
357
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
358
+ expect_existing_session_after_failed_callback(cookie)
359
+ expect(transport).to have_received(:post).twice
360
+ end
361
+ end
362
+
363
+ [nil, 'tampered'].each do |value|
364
+ it "does not create a session after an unsolicited callback with #{value ? 'an invalid' : 'no'} session cookie" do
365
+ if value
366
+ sign_in
367
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
368
+ set_cookie "#{name}=#{value}; path=/api/", URI('https://docs.example/api/')
369
+ end
370
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'unsolicited', state: 'x' * 43
371
+ expect(last_response.status).to eq(400)
372
+ get 'https://docs.example/api/v1/?resource=secret'
373
+ expect(last_response.status).to eq(200)
374
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
375
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
376
+ expect(transport).to have_received(:post).exactly(value ? 1 : 0).times
377
+ end
378
+ end
379
+
380
+ it 'returns to the validated resource directory and its fragment after sign-in' do
381
+ get 'https://docs.example/api/_documentation/oauth/login', version: '1', page: 'resources', fragment: 'resources'
382
+ expect(last_response.status).to eq(302)
383
+ authorization = URI.decode_www_form(URI(last_response.location).query).to_h
384
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
385
+ expect(last_response.status).to eq(303)
386
+ expect(last_response.location).to eq('https://docs.example/api/v1/?page=resources#resources')
387
+ end
388
+
389
+ it 'rejects malformed directory selectors and arbitrary return locations' do
390
+ ['?page[]=resources', '?page=resourcez', '?page=resources&resource=secret',
391
+ '?page=resources&return=https://foreign.example/', '?page=resources&fragment=bad%2Fanchor'].each do |query|
392
+ get "https://docs.example/api/_documentation/oauth/login#{query}"
393
+ expect(last_response.status).to eq(400)
394
+ expect(last_response.body).not_to include('foreign.example')
395
+ end
396
+ expect(transport).not_to have_received(:post)
397
+ end
398
+
399
+ it 'rejects a mismatched state, cancellation and duplicate callback inputs without exchanging a code' do
400
+ [{ state: 'x' * 43, code: 'code' }, { error: 'access_denied' }, { 'state[]' => 'bad', code: 'code' }].each do |query|
401
+ begin_login
402
+ get 'https://docs.example/api/_documentation/oauth/callback', query
403
+ expect(last_response.status).to eq(400)
404
+ expect(last_response.body).to include('Try again')
405
+ end
406
+ expect(transport).not_to have_received(:post)
407
+ end
408
+
409
+ it 'rejects an expired pending cookie' do
410
+ authorization = begin_login
411
+ allow(Time).to receive(:now).and_return(Time.now + 601)
412
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
413
+ expect(last_response.status).to eq(400)
414
+ expect(transport).not_to have_received(:post)
415
+ end
416
+
417
+ it 'bounds session expiry by its configured lifetime and clears expired sessions' do
418
+ sign_in
419
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
420
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
421
+ allow(Time).to receive(:now).and_return(Time.now + 1801)
422
+ get 'https://docs.example/api/v1/', {}, 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}", 'HTTP_AUTHORIZATION' => 'Bearer api-token'
423
+ expect(last_response.status).to eq(200)
424
+ expect(last_response.body).not_to include('ROLE_FILTERED_RESOURCE')
425
+ expect(cookie_headers).to include('max-age=0')
426
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
427
+ end
428
+
429
+ it 'clears a tampered cookie and does not make its contents an API credential' do
430
+ sign_in
431
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
432
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
433
+ get 'https://docs.example/api/v1/'
434
+ expect(last_response.status).to eq(200)
435
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
436
+ expect(cookie_headers).to include('max-age=0')
437
+ end
438
+
439
+ it 'revalidates the user role and token through the requested version' do
440
+ sign_in
441
+ get 'https://docs.example/api/v2/?resource=secret'
442
+ expect(last_response.status).to eq(404)
443
+ expect(DocumentationOAuthSpec::Config.lookups).to include([2, 'PRIVATE_ACCESS_TOKEN'])
444
+ DocumentationOAuthSpec::Config.users[[1, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
445
+ sign_in
446
+ get 'https://docs.example/api/v1/?resource=secret'
447
+ expect(last_response.status).to eq(404)
448
+ end
449
+
450
+ it 'ignores the docs cookie for OPTIONS and API GET and POST actions' do
451
+ sign_in
452
+ options 'https://docs.example/api/v1/'
453
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
454
+ get "https://docs.example#{secret_action_path(:index)}"
455
+ expect(last_response.status).to eq(401)
456
+ post "https://docs.example#{secret_action_path(:create)}", {}, 'CONTENT_TYPE' => 'application/json'
457
+ expect(last_response.status).to eq(401)
458
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
459
+ end
460
+
461
+ it 'uses only the cookie token despite explicit Authorization, custom and query credentials' do
462
+ sign_in
463
+ DocumentationOAuthSpec::Config.users[[1, 'explicit']] = DocumentationOAuthSpec::User.new('member')
464
+ [{ 'HTTP_AUTHORIZATION' => 'Bearer explicit' }, { 'HTTP_AUTHORIZATION' => 'broken' },
465
+ { 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'explicit' }].each do |env|
466
+ DocumentationOAuthSpec::Config.lookups.clear
467
+ post_authenticated.clear
468
+ get 'https://docs.example/api/v1/', {}, env
469
+ expect(last_response.status).to eq(200)
470
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
471
+ expect(post_authenticated).to eq(['admin'])
472
+ expect(last_response.body).to include('class="docs-identity">admin</span>')
473
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(
474
+ authorization: env['HTTP_AUTHORIZATION'], custom: env['HTTP_X_HAVEAPI_OAUTH2_TOKEN']
475
+ )
476
+ end
477
+ DocumentationOAuthSpec::Config.lookups.clear
478
+ get 'https://docs.example/api/v1/?access_token=explicit'
479
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
480
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(query: 'access_token=explicit', parameters: hash_including('access_token' => 'explicit'))
481
+ end
482
+
483
+ it 'ignores the cookie on another origin and on plain HTTP' do
484
+ sign_in
485
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
486
+ cookie = "#{name}=#{Rack::Utils.escape(value)}"
487
+ get 'http://docs.example/api/v1/', {}, 'HTTP_COOKIE' => cookie
488
+ get 'https://foreign.example/api/v1/', {}, 'HTTP_COOKIE' => cookie
489
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
490
+ end
491
+
492
+ it 'ignores explicit token conflicts and metadata-declared API credentials for documentation identity' do
493
+ sign_in
494
+ get 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two'
495
+ expect(last_response.status).to eq(200)
496
+ expect(last_response.body).to include('class="docs-identity">admin</span>')
497
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(authorization: 'Bearer one', custom: 'two')
498
+ metadata = api_server.auth_chain.provider_descriptions(1)
499
+ allow(api_server.auth_chain).to receive(:provider_descriptions).and_return(metadata.merge(
500
+ token: { http_header: 'X-Alternate-Token', query_parameter: :_alternate }
501
+ ))
502
+ get 'https://docs.example/api/v1/', {}, 'HTTP_X_ALTERNATE_TOKEN' => ''
503
+ get 'https://docs.example/api/v1/?_alternate='
504
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(3) { [1, 'PRIVATE_ACCESS_TOKEN'] })
505
+ end
506
+
507
+ it 'keeps documentation anonymous without a valid cookie despite API credentials' do
508
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
509
+ headers = { 'HTTP_AUTHORIZATION' => 'Bearer api-token', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'another-token' }
510
+ [nil, 'tampered'].each do |cookie|
511
+ env = headers.dup
512
+ env['HTTP_COOKIE'] = "haveapi_docs_#{Digest::SHA256.hexdigest('/api/')[0, 12]}_session=#{cookie}" if cookie
513
+ get 'https://docs.example/api/v1/?access_token=query-token', {}, env
514
+ expect(last_response.status).to eq(200)
515
+ expect(last_response.headers['www-authenticate']).to be_nil
516
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
517
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
518
+ end
519
+ end
520
+
521
+ it 'validates documentation HEAD through the cookie token without API credential fallback' do
522
+ sign_in
523
+ post_authenticated.clear
524
+ head 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer other'
525
+ expect(last_response.status).to eq(200)
526
+ expect(last_response.body).to eq('')
527
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
528
+ expect(post_authenticated).to eq(['admin'])
529
+ expect(DocumentationOAuthSpec::Config.requests.last[:authorization]).to eq('Bearer other')
530
+ end
531
+
532
+ it 'resolves multiversion index identity independently for each version without the API chain' do
533
+ sign_in
534
+ DocumentationOAuthSpec::Config.users[[2, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
535
+ allow(api_server.auth_chain).to receive(:authenticate).and_call_original
536
+ allow(api_server).to receive(:describe).and_call_original
537
+ post_authenticated.clear
538
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
539
+ expect(last_response.status).to eq(200)
540
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN'], [2, 'PRIVATE_ACCESS_TOKEN']])
541
+ expect(post_authenticated).to eq(%w[admin member])
542
+ expect(api_server).to have_received(:describe).once
543
+ expect(api_server.auth_chain).not_to have_received(:authenticate)
544
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
545
+ end
546
+
547
+ it 'still uses explicit API credentials for OPTIONS without authenticating through the docs cookie' do
548
+ sign_in
549
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('member')
550
+ options 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
551
+ expect(last_response.status).to eq(200)
552
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'api-token']])
553
+ expect(last_response.body).not_to include('ROLE_FILTERED_RESOURCE')
554
+ end
555
+
556
+ it 'revalidates the cookie on keyed and conditional search requests and rejects a changed permission key' do
557
+ sign_in
558
+ get 'https://docs.example/api/v1/'
559
+ key = last_response.body[/data-search-view-key="([a-f0-9]{64})"/, 1]
560
+ expect(key).not_to be_nil
561
+ url = "https://docs.example/api/v1/?page=resources&search_index=#{key}"
562
+ DocumentationOAuthSpec::Config.lookups.clear
563
+ get url, {}, 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two'
564
+ expect(last_response.status).to eq(200)
565
+ expect(last_response.headers['cache-control']).to eq('private, max-age=86400, immutable')
566
+ get url, {}, 'HTTP_IF_NONE_MATCH' => %("#{key}")
567
+ expect(last_response.status).to eq(304)
568
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(2) { [1, 'PRIVATE_ACCESS_TOKEN'] })
569
+ DocumentationOAuthSpec::Config.users[[1, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
570
+ get url
571
+ expect(last_response.status).to eq(409)
572
+ expect(last_response.body).not_to include('data-documentation-search-index')
573
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(3) { [1, 'PRIVATE_ACCESS_TOKEN'] })
574
+ end
575
+
576
+ it 'does not privately cache a matching anonymous index while clearing an invalid cookie' do
577
+ get 'https://docs.example/api/v1/'
578
+ key = last_response.body[/data-search-view-key="([a-f0-9]{64})"/, 1]
579
+ expect(key).not_to be_nil
580
+ name = "haveapi_docs_#{Digest::SHA256.hexdigest('/api/')[0, 12]}_session"
581
+ get "https://docs.example/api/v1/?page=resources&search_index=#{key}", {}, 'HTTP_COOKIE' => "#{name}=tampered"
582
+ expect(last_response.status).to eq(200)
583
+ expect(last_response.headers['cache-control']).to eq('private, no-store')
584
+ expect(last_response.headers['etag']).to be_nil
585
+ expect(cookie_headers).to include("#{name}=", 'max-age=0')
586
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
587
+ end
588
+
589
+ it 'clears a revoked cookie and remains anonymous instead of trying a valid API token' do
590
+ sign_in
591
+ DocumentationOAuthSpec::Config.users.delete([1, 'PRIVATE_ACCESS_TOKEN'])
592
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
593
+ get 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
594
+ expect(last_response.status).to eq(200)
595
+ expect(last_response.headers['www-authenticate']).to be_nil
596
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
597
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
598
+ expect(cookie_headers).to include('max-age=0')
599
+ end
600
+
601
+ it 'binds encrypted sessions to the configured client even with the same server secret and mount' do
602
+ sign_in
603
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
604
+ other = HaveAPI::Server.new(self.class::ApiModule)
605
+ other.use_version(1)
606
+ other.auth_chain << DocumentationOAuthSpec::Provider
607
+ other.documentation_oauth(**oauth_options, client_id: 'different-client')
608
+ other.mount('/api/')
609
+ response = Rack::MockRequest.new(other.app).get('https://docs.example/api/v1/',
610
+ 'HTTP_HOST' => 'docs.example',
611
+ 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}")
612
+ expect(response.status).to eq(200)
613
+ expect(response.body).not_to include('ROLE_FILTERED_RESOURCE')
614
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
615
+ end
616
+
617
+ it 'requires same-origin and session CSRF before revocation' do
618
+ sign_in
619
+ post 'https://docs.example/api/_documentation/oauth/logout', csrf: 'wrong'
620
+ expect(last_response.status).to eq(403)
621
+ get 'https://docs.example/api/v1/'
622
+ csrf = last_response.body[/name="csrf" value="([^"]+)"/, 1]
623
+ expect(csrf).not_to be_nil
624
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://foreign.example'
625
+ expect(last_response.status).to eq(403)
626
+ expect(transport).to have_received(:post).once
627
+ get 'https://docs.example/api/v1/?resource=secret'
628
+ expect(last_response.status).to eq(200)
629
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE')
630
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
631
+ expect(last_response.status).to eq(303)
632
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
633
+ expect(cookie_headers).to include('max-age=0')
634
+ DocumentationOAuthSpec::Config.lookups.clear
635
+ get 'https://docs.example/api/v1/?resource=secret'
636
+ expect(last_response.status).to eq(200)
637
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
638
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
639
+ end
640
+
641
+ it 'clears local cookies and reports the revoke failure without leaking details' do
642
+ sign_in
643
+ get 'https://docs.example/api/v1/'
644
+ csrf = last_response.body[/name="csrf" value="([^"]+)"/, 1]
645
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_PROVIDER_ERROR')
646
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
647
+ expect(last_response.status).to eq(502)
648
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
649
+ expect(last_response.body).to include('Local sign-out succeeded', 'may remain until it expires')
650
+ expect(last_response.body).not_to include('PRIVATE_PROVIDER_ERROR', 'PRIVATE_ACCESS_TOKEN')
651
+ expect(cookie_headers).to include('max-age=0')
652
+ end
653
+
654
+ it 'disables user switching by default even for an authenticated documentation cookie' do
655
+ sign_in
656
+ controls = capture_controls
657
+ csrf = session_csrf
658
+ expect(controls.last).to include(signed_in: true)
659
+ expect(controls.last).not_to have_key(:switch_user)
660
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
661
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'))
662
+ end
663
+
664
+ it 'requires a strict Boolean switch-user capability' do
665
+ [nil, 0, 1, 'true', 'false', [], {}].each do |value|
666
+ expect { described_class.new(**oauth_options, switch_user: value) }.to raise_error(described_class::Invalid)
667
+ end
668
+ expect { described_class.new(**oauth_options, switch_user: false) }.not_to raise_error
669
+ end
670
+
671
+ context 'with user switching enabled' do
672
+ let(:oauth_options) { super().merge(switch_user: true) }
673
+
674
+ it 'offers switching only after validated documentation-cookie authentication' do
675
+ controls = capture_controls
676
+ get 'https://docs.example/api/v1/'
677
+ expect(controls.last).to include(signed_in: false, csrf_token: nil)
678
+ expect(controls.last).not_to have_key(:switch_user)
679
+ sign_in
680
+ get 'https://docs.example/api/v1/'
681
+ expect(controls.last).to include(signed_in: true, switch_user: true)
682
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
683
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
684
+ get 'https://docs.example/api/v1/'
685
+ expect(controls.last).to include(signed_in: false, csrf_token: nil)
686
+ expect(controls.last).not_to have_key(:switch_user)
687
+ end
688
+
689
+ it 'revokes and clears the old session before starting fresh state and PKCE at the fixed login' do
690
+ original = sign_in
691
+ csrf = session_csrf
692
+ post 'https://docs.example/api/_documentation/oauth/logout',
693
+ { csrf:, switch_user: '1', version: '2', return: 'https://foreign.example/', scope: 'other' },
694
+ 'HTTP_ORIGIN' => 'https://docs.example'
695
+ expect(last_response.status).to eq(303)
696
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
697
+ 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'))
699
+ get last_response.location
700
+ expect(last_response.status).to eq(302)
701
+ fresh = URI.decode_www_form(URI(last_response.location).query).to_h
702
+ expect(fresh).to include('scope' => 'all', 'code_challenge_method' => 'S256')
703
+ expect(fresh['state']).not_to eq(original['state'])
704
+ expect(fresh['code_challenge']).not_to eq(original['code_challenge'])
705
+ expect(fresh.keys & %w[prompt select_account]).to be_empty
706
+ get 'https://docs.example/api/_documentation/oauth/callback', error: 'access_denied', state: fresh.fetch('state')
707
+ expect(last_response.status).to eq(400)
708
+ DocumentationOAuthSpec::Config.lookups.clear
709
+ get 'https://docs.example/api/v1/'
710
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
711
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
712
+ expect(transport).to have_received(:post).twice
713
+ end
714
+
715
+ [['', 'switch_user=0'], ['', 'switch_user='], ['', 'switch_user=true'],
716
+ ['', 'switch_user=1&switch_user=1'], ['', 'switch_user[]=1'],
717
+ ['', 'switch_user[mode]=1'], ['?switch_user=1', 'switch_user=1'],
718
+ ['?switch_user=1&switch_user=1', '']].each do |query, fields|
719
+ it "rejects malformed or repeated switch fields #{query} #{fields} before any sign-out effect" do
720
+ sign_in
721
+ csrf = session_csrf
722
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
723
+ expect_switch_refused(cookie, query, "#{URI.encode_www_form(csrf:)}&#{fields}")
724
+ end
725
+ end
726
+
727
+ it 'rejects multipart switching without extending the normal logout parser' do
728
+ sign_in
729
+ csrf = session_csrf
730
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
731
+ boundary = 'documentation-switch-boundary'
732
+ body = { csrf:, switch_user: '1' }.map do |name, value|
733
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
734
+ end.join + "--#{boundary}--\r\n"
735
+ expect_switch_refused(cookie, '', body, headers: { 'CONTENT_TYPE' => "multipart/form-data; boundary=#{boundary}" })
736
+ end
737
+
738
+ it 'requires the session CSRF and same origin before switching' do
739
+ sign_in
740
+ csrf = session_csrf
741
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
742
+ expect_switch_refused(cookie, '', URI.encode_www_form(switch_user: '1'))
743
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf: 'x' * 43, switch_user: '1'))
744
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'), origin: 'https://foreign.example')
745
+ end
746
+
747
+ it 'keeps truthful local sign-out failure and does not redirect to fresh login when revocation fails' do
748
+ sign_in
749
+ csrf = session_csrf
750
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_REVOKE_ERROR')
751
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
752
+ expect(last_response.status).to eq(502)
753
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
754
+ expect(last_response.headers['location']).to be_nil
755
+ expect(last_response.body).to include('Local sign-out succeeded', 'may remain until it expires')
756
+ expect(last_response.body).not_to include('PRIVATE_REVOKE_ERROR')
757
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
758
+ DocumentationOAuthSpec::Config.lookups.clear
759
+ get 'https://docs.example/api/v1/'
760
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
761
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
762
+ expect(transport).to have_received(:post).twice
763
+ end
764
+
765
+ context 'with one version and an ordinary Basic provider alongside OAuth' do
766
+ let(:api_server) { build_api_server(versions: [1], basic: true) }
767
+
768
+ it 'presents the validated cookie identity and switch capability on the single-version index' do
769
+ sign_in
770
+ controls = capture_controls
771
+ get 'https://docs.example/api/'
772
+ expect(last_response.status).to eq(200)
773
+ expect(last_response.body).to include('data-documentation-menu', 'admin', 'name="switch_user" value="1"')
774
+ expect(controls.last).to include(signed_in: true, switch_user: true)
775
+ expect(DocumentationOAuthSpec::Config.lookups.last).to eq([1, 'PRIVATE_ACCESS_TOKEN'])
776
+ end
777
+
778
+ it 'uses cookie B identity, filtering and controls while Basic A remains an ordinary API credential' do
779
+ sign_in
780
+ csrf = session_csrf
781
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
782
+ controls = capture_controls
783
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
784
+ DocumentationOAuthSpec::Config.lookups.clear
785
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
786
+ expect(last_response.status).to eq(200)
787
+ expect(last_response.body).to include('data-documentation-menu', 'class="docs-identity">admin</span>', 'name="switch_user"')
788
+ expect(last_response.body).not_to include('class="docs-identity">basic-reader</span>')
789
+ expect(controls.last).to include(signed_in: true, switch_user: true)
790
+ get 'https://docs.example/api/v1/?resource=secret', {}, 'HTTP_AUTHORIZATION' => authorization
791
+ expect(last_response.status).to eq(200)
792
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN'], [1, 'PRIVATE_ACCESS_TOKEN']])
793
+ expect(post_authenticated.last).to eq('admin')
794
+ DocumentationOAuthSpec::Config.lookups.clear
795
+ get "https://docs.example#{secret_action_path(:index)}", {}, 'HTTP_AUTHORIZATION' => authorization
796
+ expect(last_response.status).to eq(403)
797
+ expect(post_authenticated.last).to eq('basic-reader')
798
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
799
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' },
800
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_AUTHORIZATION' => authorization
801
+ expect(last_response.status).to eq(303)
802
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
803
+ expect(rack_mock_session.cookie_jar.to_hash[cookie.first].to_s).to be_empty
804
+ end
805
+
806
+ it 'keeps cookie B identity for malformed or conflicting API credentials' do
807
+ sign_in
808
+ controls = capture_controls
809
+ [{ 'HTTP_AUTHORIZATION' => 'broken' },
810
+ { 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two' }].each do |headers|
811
+ DocumentationOAuthSpec::Config.lookups.clear
812
+ get 'https://docs.example/api/v1/', {}, headers
813
+ expect(last_response.status).to eq(200)
814
+ expect(last_response.body).to include('class="docs-identity">admin</span>', 'name="switch_user"', 'name="csrf"')
815
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
816
+ expect(controls.last).to include(signed_in: true, switch_user: true)
817
+ end
818
+ end
819
+
820
+ it 'keeps absent or tampered documentation sessions anonymous despite valid Basic A' do
821
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
822
+ allow(api_server.auth_chain).to receive(:authenticate).and_call_original
823
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
824
+ expect(last_response.status).to eq(200)
825
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"', 'name="switch_user"')
826
+ sign_in
827
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
828
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
829
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
830
+ expect(last_response.status).to eq(200)
831
+ expect(last_response.headers['www-authenticate']).to be_nil
832
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"', 'name="switch_user"')
833
+ expect(api_server.auth_chain).not_to have_received(:authenticate)
834
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
835
+ end
836
+
837
+ it 'signs out the documentation cookie with cached Basic present and does not revoke Basic identity' do
838
+ sign_in
839
+ csrf = session_csrf
840
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
841
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: },
842
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_AUTHORIZATION' => authorization
843
+ expect(last_response.status).to eq(303)
844
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
845
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
846
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
847
+ get "https://docs.example#{secret_action_path(:index)}", {}, 'HTTP_AUTHORIZATION' => authorization
848
+ expect(last_response.status).to eq(403)
849
+ expect(post_authenticated.last).to eq('basic-reader')
850
+ end
851
+ end
852
+ end
853
+
854
+ [{}, { access_token: 'token', token_type: 'Bearer', expires_in: 0 },
855
+ { access_token: 'token', token_type: 'Bearer', expires_in: 'Infinity' },
856
+ { access_token: 'x' * 4096, token_type: 'Bearer', expires_in: 60 }].each do |response|
857
+ it "rejects invalid token reply shape #{response.keys.join('/')} without issuing an authenticated cookie" do
858
+ allow(transport).to receive(:post).and_return(JSON.generate(response))
859
+ authorization = begin_login
860
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
861
+ expect(last_response.status).to eq(400)
862
+ expect(cookie_headers).to include('max-age=0')
863
+ expect(last_response.body).not_to include('CLIENT_SECRET_NOT_FOR_HTML')
864
+ end
865
+ end
866
+
867
+ it 'rejects arbitrary return URLs, arrays and credential-like query keys' do
868
+ ['?return=https://foreign.example/', '?version[]=1', '?version=1&version=2', '?access_token=secret', '?fragment=bad%2Fanchor'].each do |query|
869
+ get "https://docs.example/api/_documentation/oauth/login#{query}"
870
+ expect(last_response.status).to eq(400)
871
+ expect(last_response.body).not_to include('foreign.example', 'secret')
872
+ end
873
+ end
874
+
875
+ it 'rejects repeated configuration and configuration after mount without inspecting secrets' do
876
+ app
877
+ expect { api_server.documentation_oauth(**oauth_options) }.to raise_error(ArgumentError)
878
+ expect(api_server.documentation_oauth.inspect).not_to include(oauth_options[:client_secret], oauth_options[:session_secret])
879
+ unconfigured = HaveAPI::Server.new(self.class::ApiModule)
880
+ unconfigured.use_version(1)
881
+ unconfigured.mount('/api/')
882
+ expect { unconfigured.documentation_oauth(**oauth_options) }.to raise_error(ArgumentError, /before mount/)
883
+ end
884
+
885
+ it 'rejects insecure config, missing secrets, unknown keys and invalid lifetimes/scopes' do
886
+ [{ redirect_uri: 'http://docs.example/api/_documentation/oauth/callback' }, { session_secret: 'short' },
887
+ { client_secret: [] }, { unknown: 'PRIVATE' }, { session_lifetime: 0 }, { session_lifetime: 86_401 },
888
+ { scope: ['all wrong'] }].each do |changes|
889
+ expect { described_class.new(**oauth_options, **changes) }.to raise_error(described_class::Invalid)
890
+ end
891
+ end
892
+
893
+ it 'requires a matching mounted callback and OAuth provider metadata' do
894
+ api = HaveAPI::Server.new(self.class::ApiModule)
895
+ api.use_version([1, 2])
896
+ api.default_version = 1
897
+ api.documentation_oauth(**oauth_options)
898
+ expect { api.mount('/different/') }.to raise_error(described_class::Invalid)
899
+ second = HaveAPI::Server.new(self.class::ApiModule)
900
+ second.use_version(1)
901
+ second.documentation_oauth(**oauth_options)
902
+ expect { second.mount('/api/') }.to raise_error(described_class::Invalid)
903
+ end
904
+
905
+ it 'reads only requested-version provider metadata without describing resources' do
906
+ app
907
+ allow(api_server).to receive(:describe_version).and_call_original
908
+ expect(api_server.auth_chain.provider_descriptions(1)).to include(:oauth2)
909
+ expect(api_server.auth_chain.provider_descriptions('absent')).to eq({})
910
+ expect(api_server).not_to have_received(:describe_version)
911
+ end
912
+ end