overseer-testing-protocol 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +19 -0
- data/README.md +75 -0
- data/SOURCE.json +6 -0
- data/bin/overseer-testing-conformance +53 -0
- data/docs/testing-control-implementation-guide.md +116 -0
- data/docs/testing-control-protocol.md +432 -0
- data/lib/overseer/testing_control/conformance/case_file.rb +120 -0
- data/lib/overseer/testing_control/conformance/http_transport.rb +114 -0
- data/lib/overseer/testing_control/conformance/report.rb +104 -0
- data/lib/overseer/testing_control/conformance/runner.rb +783 -0
- data/lib/overseer/testing_control/discovery.rb +79 -0
- data/lib/overseer/testing_control/json_subset_matcher.rb +120 -0
- data/lib/overseer/testing_control/protocol_v3.rb +298 -0
- data/lib/overseer/testing_control/redaction.rb +111 -0
- data/lib/overseer/testing_protocol.rb +9 -0
- data/protocol/testing-control/v3/conformance-case.schema.json +151 -0
- data/protocol/testing-control/v3/conformance-report.schema.json +255 -0
- data/protocol/testing-control/v3/fixtures/capabilities-response.json +147 -0
- data/protocol/testing-control/v3/fixtures/conformance-case.json +23 -0
- data/protocol/testing-control/v3/fixtures/conformance-report.json +88 -0
- data/protocol/testing-control/v3/fixtures/error-response.json +20 -0
- data/protocol/testing-control/v3/fixtures/manifest.json +13 -0
- data/protocol/testing-control/v3/fixtures/probe-request.json +20 -0
- data/protocol/testing-control/v3/fixtures/probe-response.json +22 -0
- data/protocol/testing-control/v3/fixtures/reset-response.json +15 -0
- data/protocol/testing-control/v3/fixtures/sink-query-request.json +26 -0
- data/protocol/testing-control/v3/fixtures/sink-query-response.json +39 -0
- data/protocol/testing-control/v3/fixtures/state-request.json +20 -0
- data/protocol/testing-control/v3/fixtures/state-response.json +23 -0
- data/protocol/testing-control/v3/openapi.yaml +343 -0
- data/protocol/testing-control/v3/schema.json +772 -0
- metadata +85 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'json_schemer'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
require_relative '../json_subset_matcher'
|
|
9
|
+
|
|
10
|
+
module Overseer
|
|
11
|
+
module TestingControl
|
|
12
|
+
module Conformance
|
|
13
|
+
class Runner
|
|
14
|
+
attr_reader :discovery
|
|
15
|
+
|
|
16
|
+
class DiscoveryRejected < StandardError; end
|
|
17
|
+
|
|
18
|
+
ExpectedIdentity = Data.define(:application_id, :application_version, :environment_id)
|
|
19
|
+
Dependencies = Data.define(:transport, :clock, :random)
|
|
20
|
+
DEFAULT_DEPENDENCIES = Dependencies.new(transport: nil, clock: Time, random: SecureRandom)
|
|
21
|
+
INITIAL_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
22
|
+
ERROR_STATUSES = {
|
|
23
|
+
'invalid-request' => 400,
|
|
24
|
+
'capability-not-found' => 404,
|
|
25
|
+
'protocol-version-unsupported' => 409,
|
|
26
|
+
'reset-requires-restart' => 409,
|
|
27
|
+
'payload-too-large' => 413,
|
|
28
|
+
'schema-validation-failed' => 422,
|
|
29
|
+
'limit-exceeded' => 422,
|
|
30
|
+
'runtime-unavailable' => 503,
|
|
31
|
+
'internal-error' => 500
|
|
32
|
+
}.freeze
|
|
33
|
+
RAW_EXCEPTION_PATTERN = %r{
|
|
34
|
+
<!DOCTYPE|<html|\bbacktrace\b|\bstack\s+trace\b|
|
|
35
|
+
/[A-Za-z0-9_./-]+\.(?:rb|js|py|java|go):\d+
|
|
36
|
+
}ix
|
|
37
|
+
|
|
38
|
+
# rubocop:disable Metrics/ParameterLists
|
|
39
|
+
def initialize(
|
|
40
|
+
origin:,
|
|
41
|
+
base_path:,
|
|
42
|
+
target_label:,
|
|
43
|
+
expected_identity:,
|
|
44
|
+
required_capabilities:,
|
|
45
|
+
cases:,
|
|
46
|
+
lifecycle:,
|
|
47
|
+
redaction_policy:,
|
|
48
|
+
restart: nil,
|
|
49
|
+
cancellation: nil,
|
|
50
|
+
progress: nil,
|
|
51
|
+
discovery_validator: nil,
|
|
52
|
+
dependencies: DEFAULT_DEPENDENCIES
|
|
53
|
+
)
|
|
54
|
+
@origin = HTTPTransport.origin!(origin)
|
|
55
|
+
@base_path = base_path
|
|
56
|
+
@target_label = target_label
|
|
57
|
+
@expected_identity = expected_identity
|
|
58
|
+
@required_capabilities = required_capabilities
|
|
59
|
+
@cases = cases&.fetch('cases', []) || []
|
|
60
|
+
@lifecycle = lifecycle
|
|
61
|
+
@redaction_policy = redaction_policy
|
|
62
|
+
@restart = restart
|
|
63
|
+
@cancellation = cancellation
|
|
64
|
+
@progress = progress
|
|
65
|
+
@discovery_validator = discovery_validator
|
|
66
|
+
@dependencies = dependencies
|
|
67
|
+
@transport = dependencies.transport || HTTPTransport.new(origin: @origin, base_path:)
|
|
68
|
+
@run_id = ProtocolV3.identifier!("conformance-#{dependencies.random.hex(8)}", label: 'conformance run ID')
|
|
69
|
+
@checks = []
|
|
70
|
+
@negative_responses = []
|
|
71
|
+
@response_contract_safe = true
|
|
72
|
+
@capabilities_document = nil
|
|
73
|
+
@runtime_data = nil
|
|
74
|
+
@identity = nil
|
|
75
|
+
@reset_ready = false
|
|
76
|
+
end
|
|
77
|
+
# rubocop:enable Metrics/ParameterLists
|
|
78
|
+
|
|
79
|
+
def call
|
|
80
|
+
started_at = timestamp
|
|
81
|
+
run_core_checks
|
|
82
|
+
run_capability_cases
|
|
83
|
+
completed_at = timestamp
|
|
84
|
+
report = {
|
|
85
|
+
'schemaVersion' => '1',
|
|
86
|
+
'protocolVersion' => ProtocolV3::VERSION,
|
|
87
|
+
'target' => {
|
|
88
|
+
'label' => safe_message(@target_label),
|
|
89
|
+
'origin' => @origin,
|
|
90
|
+
'sourceVersion' => @expected_identity.application_version
|
|
91
|
+
},
|
|
92
|
+
'startedAt' => started_at,
|
|
93
|
+
'completedAt' => completed_at,
|
|
94
|
+
'lifecycle' => @lifecycle,
|
|
95
|
+
'identity' => @identity,
|
|
96
|
+
'capabilities' => report_capabilities,
|
|
97
|
+
'checks' => @checks,
|
|
98
|
+
'aggregateStatus' => @checks.all? { |check| check.fetch('status') == 'passed' } ? 'passed' : 'failed'
|
|
99
|
+
}
|
|
100
|
+
Report.validate!(report)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def run_core_checks
|
|
106
|
+
check('core.discovery') do
|
|
107
|
+
@capabilities_document = successful_exchange(
|
|
108
|
+
:get,
|
|
109
|
+
ProtocolV3::PATHS.fetch(:capabilities),
|
|
110
|
+
kind: :capabilities,
|
|
111
|
+
max_response_bytes: INITIAL_MAX_RESPONSE_BYTES
|
|
112
|
+
)
|
|
113
|
+
'Discovered testing-control protocol v3 through the canonical capabilities endpoint.'
|
|
114
|
+
end
|
|
115
|
+
check('core.runtime-identity') { validate_runtime_identity }
|
|
116
|
+
check('core.capabilities') { validate_capabilities }
|
|
117
|
+
check('core.unsupported-version') { validate_unsupported_version }
|
|
118
|
+
check('core.malformed-json') { validate_malformed_json }
|
|
119
|
+
check('core.request-media-type') { validate_request_media_type }
|
|
120
|
+
check('core.unknown-arrangement') { validate_unknown_capability(:states, :state_request) }
|
|
121
|
+
check('core.unknown-observation') { validate_unknown_capability(:probes, :probe_request) }
|
|
122
|
+
check('core.schema-invalid-request') { validate_schema_invalid_request }
|
|
123
|
+
@reset_ready = check('core.reset') { perform_reset }
|
|
124
|
+
check('core.error-safety') { validate_error_safety }
|
|
125
|
+
check('core.response-contract') { validate_response_contract }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_runtime_identity
|
|
129
|
+
require_capabilities!
|
|
130
|
+
data = @capabilities_document.fetch('data')
|
|
131
|
+
@identity = {
|
|
132
|
+
'applicationId' => data.dig('application', 'id'),
|
|
133
|
+
'applicationVersion' => data.dig('application', 'version'),
|
|
134
|
+
'environmentId' => data.dig('environment', 'id')
|
|
135
|
+
}
|
|
136
|
+
expected = @expected_identity
|
|
137
|
+
mismatches = []
|
|
138
|
+
mismatches << 'application ID' unless @identity.fetch('applicationId') == expected.application_id
|
|
139
|
+
mismatches << 'application source version' unless
|
|
140
|
+
@identity.fetch('applicationVersion') == expected.application_version
|
|
141
|
+
mismatches << 'environment ID' unless @identity.fetch('environmentId') == expected.environment_id
|
|
142
|
+
raise CheckFailure.new('runtime-identity', "Runtime identity mismatch: #{mismatches.join(', ')}") unless
|
|
143
|
+
mismatches.empty?
|
|
144
|
+
|
|
145
|
+
'Runtime application, source version, and environment identity match the external witness.'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_capabilities
|
|
149
|
+
require_capabilities!
|
|
150
|
+
ProtocolV3.validate_capabilities!(@capabilities_document, required: @required_capabilities)
|
|
151
|
+
discovery = Discovery.build(@capabilities_document)
|
|
152
|
+
validate_discovery!(discovery)
|
|
153
|
+
@runtime_data = @capabilities_document.fetch('data')
|
|
154
|
+
@discovery = discovery
|
|
155
|
+
'Capability advertisement and every product-owned JSON Schema are valid and bounded.'
|
|
156
|
+
rescue ProtocolV3::Error, Discovery::Error => e
|
|
157
|
+
raise CheckFailure.new('capability-advertisement', e.message)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_discovery!(discovery)
|
|
161
|
+
@discovery_validator&.call(discovery)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
raise DiscoveryRejected, e.message
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_unsupported_version
|
|
167
|
+
error_exchange(
|
|
168
|
+
:get,
|
|
169
|
+
ProtocolV3::PATHS.fetch(:capabilities),
|
|
170
|
+
code: 'protocol-version-unsupported',
|
|
171
|
+
version: '2',
|
|
172
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
173
|
+
)
|
|
174
|
+
'A non-v3 request fails explicitly with HTTP 409 and protocol-version-unsupported.'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_malformed_json
|
|
178
|
+
error_exchange(
|
|
179
|
+
:post,
|
|
180
|
+
ProtocolV3::PATHS.fetch(:states),
|
|
181
|
+
code: 'invalid-request',
|
|
182
|
+
raw_body: '{',
|
|
183
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
184
|
+
)
|
|
185
|
+
'Malformed JSON fails with a bounded structured invalid-request response.'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_request_media_type
|
|
189
|
+
body = request_envelope(
|
|
190
|
+
next_identifier,
|
|
191
|
+
data: {
|
|
192
|
+
'capability' => { 'id' => 'overseer-conformance-unknown', 'version' => '1' },
|
|
193
|
+
'input' => {}
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
error_exchange(
|
|
197
|
+
:post,
|
|
198
|
+
ProtocolV3::PATHS.fetch(:states),
|
|
199
|
+
code: 'invalid-request',
|
|
200
|
+
body:,
|
|
201
|
+
content_type: 'text/plain',
|
|
202
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
203
|
+
)
|
|
204
|
+
'A non-JSON request media type fails with a bounded structured invalid-request response.'
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_unknown_capability(path_key, kind)
|
|
208
|
+
path = ProtocolV3::PATHS.fetch(path_key)
|
|
209
|
+
body = request_envelope(
|
|
210
|
+
next_identifier,
|
|
211
|
+
data: {
|
|
212
|
+
'capability' => { 'id' => 'overseer-conformance-unknown', 'version' => '1' },
|
|
213
|
+
'input' => {}
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
ProtocolV3.validate_document!(kind, body)
|
|
217
|
+
error_exchange(
|
|
218
|
+
:post,
|
|
219
|
+
path,
|
|
220
|
+
code: 'capability-not-found',
|
|
221
|
+
body:,
|
|
222
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
223
|
+
)
|
|
224
|
+
family = path_key == :states ? 'arrangement' : 'observation'
|
|
225
|
+
"An unknown #{family} capability fails with a structured capability-not-found response."
|
|
226
|
+
rescue ProtocolV3::Error => e
|
|
227
|
+
raise CheckFailure.new('request-envelope', e.message)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_schema_invalid_request
|
|
231
|
+
unless @runtime_data
|
|
232
|
+
return 'No arrangement is advertised; product-payload schema validation is not applicable.'
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
capability = @runtime_data.fetch('states').first
|
|
236
|
+
return 'No arrangement is advertised; product-payload schema validation is not applicable.' unless capability
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
ProtocolV3.validate_value!(capability.fetch('inputSchema'), {}, label: 'conformance invalid input')
|
|
240
|
+
return 'The first arrangement accepts an empty object; its schema has no safe generic negative value.'
|
|
241
|
+
rescue ProtocolV3::Error
|
|
242
|
+
# The request below is deliberately known to be invalid against the advertised schema.
|
|
243
|
+
end
|
|
244
|
+
reference = capability.slice('id', 'version')
|
|
245
|
+
body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => {} })
|
|
246
|
+
ProtocolV3.validate_document!(:state_request, body)
|
|
247
|
+
error_exchange(
|
|
248
|
+
:post,
|
|
249
|
+
ProtocolV3::PATHS.fetch(:states),
|
|
250
|
+
code: 'schema-validation-failed',
|
|
251
|
+
body:,
|
|
252
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
253
|
+
)
|
|
254
|
+
'A payload known to violate an advertised arrangement schema fails with schema-validation-failed.'
|
|
255
|
+
rescue ProtocolV3::Error => e
|
|
256
|
+
raise CheckFailure.new('request-envelope', e.message)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
260
|
+
def perform_reset
|
|
261
|
+
require_runtime_data!
|
|
262
|
+
strategy = @runtime_data.dig('reset', 'strategy')
|
|
263
|
+
if strategy == 'in-process'
|
|
264
|
+
document = successful_exchange(
|
|
265
|
+
:put,
|
|
266
|
+
ProtocolV3::PATHS.fetch(:reset),
|
|
267
|
+
kind: :reset,
|
|
268
|
+
max_response_bytes: negotiated_max_response_bytes,
|
|
269
|
+
idempotency_key: "reset-#{next_identifier}"
|
|
270
|
+
)
|
|
271
|
+
data = document.fetch('data')
|
|
272
|
+
unless data['strategy'] == 'in-process' && data['sinksCleared'] == true
|
|
273
|
+
raise CheckFailure.new('reset', 'Reset response contradicts the advertised in-process strategy')
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
return 'In-process reset completed and reported cleared fake sinks.'
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
error_exchange(
|
|
280
|
+
:put,
|
|
281
|
+
ProtocolV3::PATHS.fetch(:reset),
|
|
282
|
+
code: 'reset-requires-restart',
|
|
283
|
+
max_response_bytes: negotiated_max_response_bytes,
|
|
284
|
+
idempotency_key: "reset-#{next_identifier}"
|
|
285
|
+
)
|
|
286
|
+
unless @restart
|
|
287
|
+
raise CheckFailure.new('runtime-lifecycle',
|
|
288
|
+
'Runtime-restart reset requires a managed runtime owner')
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
replacement = @restart.call
|
|
292
|
+
apply_replacement!(replacement)
|
|
293
|
+
document = successful_exchange(
|
|
294
|
+
:get,
|
|
295
|
+
ProtocolV3::PATHS.fetch(:capabilities),
|
|
296
|
+
kind: :capabilities,
|
|
297
|
+
max_response_bytes: INITIAL_MAX_RESPONSE_BYTES
|
|
298
|
+
)
|
|
299
|
+
ProtocolV3.validate_capabilities!(document, required: @required_capabilities)
|
|
300
|
+
replacement_discovery = Discovery.build(document)
|
|
301
|
+
unless replacement_discovery.digest == @discovery.digest
|
|
302
|
+
raise CheckFailure.new('stable-discovery', 'Replacement runtime changed its stable discovery digest')
|
|
303
|
+
end
|
|
304
|
+
unless document.dig('data', 'reset', 'strategy') == 'runtime-restart'
|
|
305
|
+
raise CheckFailure.new('reset', 'Replacement runtime changed its advertised reset strategy')
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
@capabilities_document = document
|
|
309
|
+
@runtime_data = document.fetch('data')
|
|
310
|
+
validate_runtime_identity
|
|
311
|
+
'Runtime-restart reset recreated, re-witnessed, and renegotiated the isolated runtime.'
|
|
312
|
+
rescue ProtocolV3::Error, Discovery::Error => e
|
|
313
|
+
raise CheckFailure.new('reset', e.message)
|
|
314
|
+
rescue CheckFailure
|
|
315
|
+
raise
|
|
316
|
+
rescue StandardError => e
|
|
317
|
+
raise CheckFailure.new('runtime-lifecycle', "Managed runtime restart failed (#{e.class.name})")
|
|
318
|
+
end
|
|
319
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
320
|
+
|
|
321
|
+
def validate_error_safety
|
|
322
|
+
raise CheckFailure.new('error-safety', 'No negative protocol response was available for safety validation') if
|
|
323
|
+
@negative_responses.empty?
|
|
324
|
+
unless @negative_responses.all? { |item| item.fetch(:structured) && !item.fetch(:raw_exception) }
|
|
325
|
+
raise CheckFailure.new('error-safety', 'A negative response exposed an invalid or framework-specific body')
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
'Every exercised error was schema-valid, bounded, sanitized, and free of raw framework exception content.'
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def validate_response_contract
|
|
332
|
+
unless @response_contract_safe
|
|
333
|
+
raise CheckFailure.new('response-contract',
|
|
334
|
+
'One or more responses violated media-type, size, timeout, or envelope rules')
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
'All exercised responses used application/json, stayed within byte/time bounds, and matched v3 envelopes.'
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def run_capability_cases
|
|
341
|
+
@cases.each do |scenario|
|
|
342
|
+
scenario_id = scenario.fetch('id')
|
|
343
|
+
unless @runtime_data && @reset_ready
|
|
344
|
+
add_check(
|
|
345
|
+
"scenario.#{scenario_id}.prerequisite",
|
|
346
|
+
'failed',
|
|
347
|
+
'reset',
|
|
348
|
+
'Scenario was not executed because discovery, capability validation, or reset failed.',
|
|
349
|
+
scenario_id:
|
|
350
|
+
)
|
|
351
|
+
next
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
run_capability_case(scenario)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
359
|
+
def run_capability_case(scenario)
|
|
360
|
+
scenario_id = scenario.fetch('id')
|
|
361
|
+
if scenario.fetch('resetBeforeCase')
|
|
362
|
+
reset_ok = check("scenario.#{scenario_id}.reset", scenario_id:) { perform_reset }
|
|
363
|
+
return unless reset_ok
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
arrangement = nil
|
|
367
|
+
arranged = check("scenario.#{scenario_id}.arrangement", scenario_id:) do
|
|
368
|
+
arrangement = run_arrangement(scenario.fetch('arrangement'))
|
|
369
|
+
'Arrangement matched its declared protocol-level expectation.'
|
|
370
|
+
end
|
|
371
|
+
return unless arranged && arrangement
|
|
372
|
+
return unless scenario['observation']
|
|
373
|
+
|
|
374
|
+
observation = nil
|
|
375
|
+
observed = check("scenario.#{scenario_id}.observation", scenario_id:) do
|
|
376
|
+
observation = run_observation(scenario.fetch('observation'), arrangement)
|
|
377
|
+
'Observation matched its declared protocol-level expectation and advertised result schema.'
|
|
378
|
+
end
|
|
379
|
+
return unless observed && observation && scenario['expectedObservation']
|
|
380
|
+
|
|
381
|
+
check("scenario.#{scenario_id}.assertion", scenario_id:) do
|
|
382
|
+
validate_observation_assertion!(observation, scenario.fetch('expectedObservation'))
|
|
383
|
+
'Observation result matched the trusted declarative assertion.'
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
387
|
+
|
|
388
|
+
def run_arrangement(operation)
|
|
389
|
+
reference = operation.fetch('capability')
|
|
390
|
+
input = operation.fetch('input')
|
|
391
|
+
expected = operation.fetch('expected')
|
|
392
|
+
capability = capability_for('states', reference)
|
|
393
|
+
if capability.nil? && expected.fetch('status') == 'success'
|
|
394
|
+
raise CheckFailure.new('unknown-capability', 'Trusted arrangement capability was not advertised')
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
validate_case_input!(capability, input, expected:, label: 'arrangement')
|
|
398
|
+
body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => input })
|
|
399
|
+
if expected.fetch('status') == 'error'
|
|
400
|
+
error_exchange(
|
|
401
|
+
:post,
|
|
402
|
+
ProtocolV3::PATHS.fetch(:states),
|
|
403
|
+
code: expected.fetch('errorCode'),
|
|
404
|
+
body:,
|
|
405
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
406
|
+
)
|
|
407
|
+
return nil
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
document = successful_exchange(
|
|
411
|
+
:post,
|
|
412
|
+
ProtocolV3::PATHS.fetch(:states),
|
|
413
|
+
kind: :state_response,
|
|
414
|
+
body:,
|
|
415
|
+
max_response_bytes: negotiated_max_response_bytes,
|
|
416
|
+
idempotency_key: arrangement_idempotency_key(capability)
|
|
417
|
+
)
|
|
418
|
+
data = document.fetch('data')
|
|
419
|
+
validate_returned_capability!(data, reference, 'arrangement')
|
|
420
|
+
ProtocolV3.validate_value!(capability.fetch('outputSchema'), data.fetch('output'),
|
|
421
|
+
label: 'arrangement output')
|
|
422
|
+
data
|
|
423
|
+
rescue ProtocolV3::Error => e
|
|
424
|
+
raise CheckFailure.new('capability-schema', e.message)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def run_observation(operation, arrangement)
|
|
428
|
+
reference = operation.fetch('capability')
|
|
429
|
+
expected = operation.fetch('expected')
|
|
430
|
+
input = copy_json(operation.fetch('input'))
|
|
431
|
+
operation.fetch('inputFromArrangementIdentifiers', {}).each do |input_name, identifier_name|
|
|
432
|
+
identifiers = arrangement.fetch('identifiers')
|
|
433
|
+
unless identifiers.key?(identifier_name)
|
|
434
|
+
raise CheckFailure.new('case-binding', "Arrangement identifier #{identifier_name} is unavailable")
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
input[input_name] = identifiers.fetch(identifier_name)
|
|
438
|
+
end
|
|
439
|
+
capability = capability_for('probes', reference)
|
|
440
|
+
if capability.nil? && expected.fetch('status') == 'success'
|
|
441
|
+
raise CheckFailure.new('unknown-capability', 'Trusted observation capability was not advertised')
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
validate_case_input!(capability, input, expected:, label: 'observation')
|
|
445
|
+
body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => input })
|
|
446
|
+
if expected.fetch('status') == 'error'
|
|
447
|
+
error_exchange(
|
|
448
|
+
:post,
|
|
449
|
+
ProtocolV3::PATHS.fetch(:probes),
|
|
450
|
+
code: expected.fetch('errorCode'),
|
|
451
|
+
body:,
|
|
452
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
453
|
+
)
|
|
454
|
+
return nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
document = successful_exchange(
|
|
458
|
+
:post,
|
|
459
|
+
ProtocolV3::PATHS.fetch(:probes),
|
|
460
|
+
kind: :probe_response,
|
|
461
|
+
body:,
|
|
462
|
+
max_response_bytes: negotiated_max_response_bytes
|
|
463
|
+
)
|
|
464
|
+
data = document.fetch('data')
|
|
465
|
+
validate_returned_capability!(data, reference, 'observation')
|
|
466
|
+
ProtocolV3.validate_value!(
|
|
467
|
+
capability.fetch('outputSchema'),
|
|
468
|
+
data.fetch('projection'),
|
|
469
|
+
label: 'observation result'
|
|
470
|
+
)
|
|
471
|
+
data.fetch('projection')
|
|
472
|
+
rescue ProtocolV3::Error => e
|
|
473
|
+
raise CheckFailure.new('capability-schema', e.message)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def validate_case_input!(capability, input, expected:, label:)
|
|
477
|
+
return unless capability
|
|
478
|
+
|
|
479
|
+
valid = JSONSchemer.schema(capability.fetch('inputSchema')).valid?(input)
|
|
480
|
+
if expected.fetch('status') == 'success' && !valid
|
|
481
|
+
raise CheckFailure.new('case-definition', "Trusted #{label} input violates its advertised schema")
|
|
482
|
+
end
|
|
483
|
+
return unless expected['errorCode'] == 'schema-validation-failed' && valid
|
|
484
|
+
|
|
485
|
+
raise CheckFailure.new('case-definition', "Trusted #{label} negative input is valid against its schema")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def validate_observation_assertion!(value, assertion)
|
|
489
|
+
if assertion.key?('subset')
|
|
490
|
+
result = JsonSubsetMatcher.call(expected: assertion.fetch('subset'), actual: value)
|
|
491
|
+
return if result.matched?
|
|
492
|
+
|
|
493
|
+
raise CheckFailure.new('scenario-assertion', 'Observation result does not contain the expected JSON subset')
|
|
494
|
+
end
|
|
495
|
+
errors = JSONSchemer.schema(assertion.fetch('schema')).validate(value).take(3)
|
|
496
|
+
return if errors.empty?
|
|
497
|
+
|
|
498
|
+
raise CheckFailure.new('scenario-assertion', 'Observation result does not satisfy the expected JSON Schema')
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# rubocop:disable Metrics/ParameterLists
|
|
502
|
+
def successful_exchange(method, path, kind:, max_response_bytes:, body: nil, idempotency_key: nil)
|
|
503
|
+
_response, document = exchange(
|
|
504
|
+
method,
|
|
505
|
+
path,
|
|
506
|
+
expected_status: 200,
|
|
507
|
+
success_kind: kind,
|
|
508
|
+
max_response_bytes:,
|
|
509
|
+
body:,
|
|
510
|
+
idempotency_key:
|
|
511
|
+
)
|
|
512
|
+
document
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def error_exchange(
|
|
516
|
+
method,
|
|
517
|
+
path,
|
|
518
|
+
code:,
|
|
519
|
+
max_response_bytes:,
|
|
520
|
+
version: ProtocolV3::VERSION,
|
|
521
|
+
body: nil,
|
|
522
|
+
raw_body: nil,
|
|
523
|
+
content_type: nil,
|
|
524
|
+
idempotency_key: nil
|
|
525
|
+
)
|
|
526
|
+
status = ERROR_STATUSES.fetch(code)
|
|
527
|
+
_response, document = exchange(
|
|
528
|
+
method,
|
|
529
|
+
path,
|
|
530
|
+
expected_status: status,
|
|
531
|
+
success_kind: nil,
|
|
532
|
+
expected_error_code: code,
|
|
533
|
+
max_response_bytes:,
|
|
534
|
+
version:,
|
|
535
|
+
body:,
|
|
536
|
+
raw_body:,
|
|
537
|
+
content_type:,
|
|
538
|
+
idempotency_key:
|
|
539
|
+
)
|
|
540
|
+
document
|
|
541
|
+
end
|
|
542
|
+
# rubocop:enable Metrics/ParameterLists
|
|
543
|
+
|
|
544
|
+
# HTTP conformance deliberately evaluates the whole bounded response contract in one exchange.
|
|
545
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
546
|
+
def exchange(
|
|
547
|
+
method,
|
|
548
|
+
path,
|
|
549
|
+
expected_status:,
|
|
550
|
+
success_kind:,
|
|
551
|
+
max_response_bytes:,
|
|
552
|
+
expected_error_code: nil,
|
|
553
|
+
version: ProtocolV3::VERSION,
|
|
554
|
+
body: nil,
|
|
555
|
+
raw_body: nil,
|
|
556
|
+
content_type: nil,
|
|
557
|
+
idempotency_key: nil
|
|
558
|
+
)
|
|
559
|
+
correlation_id = body&.dig('meta', 'correlationId') || next_identifier
|
|
560
|
+
encoded = raw_body || (JSON.generate(body) if body)
|
|
561
|
+
response = @transport.request(
|
|
562
|
+
method:,
|
|
563
|
+
path:,
|
|
564
|
+
headers: request_headers(
|
|
565
|
+
correlation_id:,
|
|
566
|
+
version:,
|
|
567
|
+
content: !encoded.nil?,
|
|
568
|
+
content_type:,
|
|
569
|
+
idempotency_key:
|
|
570
|
+
),
|
|
571
|
+
body: encoded,
|
|
572
|
+
max_response_bytes:
|
|
573
|
+
)
|
|
574
|
+
raw_exception = response.body.match?(RAW_EXCEPTION_PATTERN)
|
|
575
|
+
content_type = header_value(response.headers, 'Content-Type')
|
|
576
|
+
content_type = content_type.split(';', 2).first.strip.downcase if content_type
|
|
577
|
+
raise CheckFailure.new('response-media-type', 'Response media type must be application/json') unless
|
|
578
|
+
content_type == 'application/json'
|
|
579
|
+
|
|
580
|
+
document = JSON.parse(response.body)
|
|
581
|
+
ProtocolV3.validate_response_headers!(response.headers, correlation_id:)
|
|
582
|
+
kind = response.status.between?(200, 299) ? success_kind : :error
|
|
583
|
+
raise CheckFailure.new('http-status', "Unexpected successful HTTP #{response.status}") unless kind
|
|
584
|
+
|
|
585
|
+
ProtocolV3.validate_document!(kind, document)
|
|
586
|
+
ProtocolV3.validate_meta!(document, run_id: @run_id, correlation_id:)
|
|
587
|
+
if response.status != expected_status
|
|
588
|
+
raise CheckFailure.new(
|
|
589
|
+
'http-status',
|
|
590
|
+
"Expected HTTP #{expected_status}; received HTTP #{response.status}"
|
|
591
|
+
)
|
|
592
|
+
end
|
|
593
|
+
if expected_error_code && document.dig('errors', 0, 'code') != expected_error_code
|
|
594
|
+
raise CheckFailure.new(
|
|
595
|
+
'error-code',
|
|
596
|
+
"Expected #{expected_error_code}; received #{document.dig('errors', 0, 'code') || 'no error code'}"
|
|
597
|
+
)
|
|
598
|
+
end
|
|
599
|
+
if response.status >= 400
|
|
600
|
+
@negative_responses << { structured: true, raw_exception: }
|
|
601
|
+
elsif raw_exception
|
|
602
|
+
raise CheckFailure.new('error-safety', 'Successful response contains framework exception content')
|
|
603
|
+
end
|
|
604
|
+
[response, document]
|
|
605
|
+
rescue HTTPTransport::TimeoutError => e
|
|
606
|
+
@response_contract_safe = false
|
|
607
|
+
raise CheckFailure.new('timeout', e.message)
|
|
608
|
+
rescue HTTPTransport::ResponseTooLarge => e
|
|
609
|
+
@response_contract_safe = false
|
|
610
|
+
raise CheckFailure.new('response-size', e.message)
|
|
611
|
+
rescue HTTPTransport::Error => e
|
|
612
|
+
@response_contract_safe = false
|
|
613
|
+
raise CheckFailure.new('transport', e.message)
|
|
614
|
+
rescue JSON::ParserError
|
|
615
|
+
@response_contract_safe = false
|
|
616
|
+
raise CheckFailure.new('response-json', 'Response body is not valid JSON')
|
|
617
|
+
rescue ProtocolV3::Error => e
|
|
618
|
+
@response_contract_safe = false
|
|
619
|
+
raise CheckFailure.new('response-envelope', e.message)
|
|
620
|
+
rescue CheckFailure => e
|
|
621
|
+
@response_contract_safe = false if %w[response-media-type response-envelope
|
|
622
|
+
response-json].include?(e.category)
|
|
623
|
+
raise
|
|
624
|
+
end
|
|
625
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
626
|
+
|
|
627
|
+
def request_headers(correlation_id:, version:, content:, content_type: nil, idempotency_key: nil)
|
|
628
|
+
headers = ProtocolV3::HEADERS
|
|
629
|
+
{
|
|
630
|
+
'Accept' => 'application/json',
|
|
631
|
+
**(content ? { 'Content-Type' => content_type || 'application/json' } : {}),
|
|
632
|
+
headers.fetch(:version) => version,
|
|
633
|
+
headers.fetch(:run_id) => @run_id,
|
|
634
|
+
headers.fetch(:correlation_id) => correlation_id,
|
|
635
|
+
**(idempotency_key ? { headers.fetch(:idempotency_key) => idempotency_key } : {})
|
|
636
|
+
}
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def request_envelope(correlation_id, data:)
|
|
640
|
+
{
|
|
641
|
+
'protocol' => { 'name' => ProtocolV3::NAME, 'version' => ProtocolV3::VERSION },
|
|
642
|
+
'meta' => { 'runId' => @run_id, 'correlationId' => correlation_id },
|
|
643
|
+
'data' => data
|
|
644
|
+
}
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def apply_replacement!(replacement)
|
|
648
|
+
unless replacement.is_a?(Hash) && replacement[:origin] && replacement[:expected_identity]
|
|
649
|
+
raise CheckFailure.new('runtime-lifecycle', 'Managed runtime restart returned no replacement witness')
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
@origin = HTTPTransport.origin!(replacement.fetch(:origin))
|
|
653
|
+
@expected_identity = replacement.fetch(:expected_identity)
|
|
654
|
+
@transport = HTTPTransport.new(origin: @origin, base_path: @base_path) unless @dependencies.transport
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def capability_for(family, reference)
|
|
658
|
+
@runtime_data.fetch(family).find do |item|
|
|
659
|
+
item['id'] == reference.fetch('id') && item['version'] == reference.fetch('version')
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def arrangement_idempotency_key(capability)
|
|
664
|
+
return unless capability&.fetch('idempotency') == 'required'
|
|
665
|
+
|
|
666
|
+
"case-#{next_identifier}"
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def validate_returned_capability!(data, expected, label)
|
|
670
|
+
return if data.fetch('capability') == expected
|
|
671
|
+
|
|
672
|
+
raise CheckFailure.new('capability-mismatch', "#{label.capitalize} returned a different capability")
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def report_capabilities
|
|
676
|
+
return unless @runtime_data
|
|
677
|
+
|
|
678
|
+
{
|
|
679
|
+
'resetStrategy' => @runtime_data.dig('reset', 'strategy'),
|
|
680
|
+
'arrangements' => @runtime_data.fetch('states').map do |item|
|
|
681
|
+
report_capability(item, input: 'inputSchema', output: 'outputSchema')
|
|
682
|
+
end,
|
|
683
|
+
'observations' => @runtime_data.fetch('probes').map do |item|
|
|
684
|
+
report_capability(item, input: 'inputSchema', output: 'outputSchema')
|
|
685
|
+
end,
|
|
686
|
+
'sinks' => @runtime_data.fetch('sinks').map do |item|
|
|
687
|
+
report_capability(item, input: 'queryInputSchema', output: 'recordSchema')
|
|
688
|
+
end
|
|
689
|
+
}
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def report_capability(item, input:, output:)
|
|
693
|
+
{
|
|
694
|
+
'id' => item.fetch('id'),
|
|
695
|
+
'version' => item.fetch('version'),
|
|
696
|
+
'inputSchema' => Redaction.body(item.fetch(input), @redaction_policy),
|
|
697
|
+
'outputSchema' => Redaction.body(item.fetch(output), @redaction_policy)
|
|
698
|
+
}
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def negotiated_max_response_bytes
|
|
702
|
+
@runtime_data&.dig('limits', 'maxResponseBytes') || INITIAL_MAX_RESPONSE_BYTES
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def require_capabilities!
|
|
706
|
+
return if @capabilities_document
|
|
707
|
+
|
|
708
|
+
raise CheckFailure.new('prerequisite', 'Capability discovery did not complete')
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def require_runtime_data!
|
|
712
|
+
return if @runtime_data
|
|
713
|
+
|
|
714
|
+
raise CheckFailure.new('prerequisite', 'Capability validation did not complete')
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def check(id, scenario_id: nil)
|
|
718
|
+
raise CheckFailure.new('cancelled', 'Conformance was cancelled') if @cancellation&.call
|
|
719
|
+
|
|
720
|
+
@progress&.call(phase: conformance_phase(id))
|
|
721
|
+
message = yield
|
|
722
|
+
add_check(id, 'passed', 'protocol', message, scenario_id:)
|
|
723
|
+
@progress&.call(phase: conformance_phase(id))
|
|
724
|
+
true
|
|
725
|
+
rescue DiscoveryRejected
|
|
726
|
+
raise
|
|
727
|
+
rescue CheckFailure => e
|
|
728
|
+
add_check(id, 'failed', e.category, e.message, scenario_id:)
|
|
729
|
+
false
|
|
730
|
+
rescue StandardError => e
|
|
731
|
+
add_check(id, 'failed', 'runner', "Conformance check failed safely (#{e.class.name})", scenario_id:)
|
|
732
|
+
false
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def add_check(id, status, category, message, scenario_id: nil)
|
|
736
|
+
@checks << {
|
|
737
|
+
'id' => id,
|
|
738
|
+
'status' => status,
|
|
739
|
+
'category' => category,
|
|
740
|
+
'message' => safe_message(message),
|
|
741
|
+
**(scenario_id ? { 'scenarioId' => scenario_id } : {})
|
|
742
|
+
}
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def conformance_phase(id)
|
|
746
|
+
%w[core.discovery core.runtime-identity core.capabilities].include?(id) ? 'discovery' : 'conformance'
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def safe_message(value)
|
|
750
|
+
Redaction.string(value.to_s).byteslice(0, 500).to_s.scrub
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def next_identifier
|
|
754
|
+
ProtocolV3.identifier!(@dependencies.random.uuid, label: 'conformance correlation ID')
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def timestamp
|
|
758
|
+
@dependencies.clock.now.utc.iso8601(3)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def copy_json(value)
|
|
762
|
+
JSON.parse(JSON.generate(value))
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def header_value(headers, name)
|
|
766
|
+
headers.each do |candidate, value|
|
|
767
|
+
return Array(value).first if candidate.to_s.casecmp?(name)
|
|
768
|
+
end
|
|
769
|
+
nil
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
class CheckFailure < StandardError
|
|
773
|
+
attr_reader :category
|
|
774
|
+
|
|
775
|
+
def initialize(category, message)
|
|
776
|
+
@category = category
|
|
777
|
+
super(message)
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
end
|