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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Overseer
|
|
7
|
+
module TestingControl
|
|
8
|
+
# A stable semantic projection of a fully validated v3 capabilities document.
|
|
9
|
+
module Discovery
|
|
10
|
+
MAX_CAPABILITIES_PER_FAMILY = 128
|
|
11
|
+
MAX_BYTES = 256 * 1024
|
|
12
|
+
Result = Data.define(:snapshot, :digest, :bytes)
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def build(document)
|
|
19
|
+
data = document.fetch('data')
|
|
20
|
+
%w[states probes sinks].each do |family|
|
|
21
|
+
raise Error, "Testing-control discovery exceeds #{family} capability boundary" if
|
|
22
|
+
data.fetch(family).length > MAX_CAPABILITIES_PER_FAMILY
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
snapshot = {
|
|
26
|
+
'discoveryVersion' => '1',
|
|
27
|
+
'protocol' => document.fetch('protocol').slice('name', 'version'),
|
|
28
|
+
'application' => data.fetch('application').slice('id', 'version'),
|
|
29
|
+
'environment' => data.fetch('environment').slice('kind', 'isolated'),
|
|
30
|
+
'features' => data.fetch('features').sort,
|
|
31
|
+
'limits' => data.fetch('limits'),
|
|
32
|
+
'safety' => data.fetch('safety'),
|
|
33
|
+
'reset' => data.fetch('reset'),
|
|
34
|
+
'states' => capabilities(
|
|
35
|
+
data.fetch('states'),
|
|
36
|
+
%w[id version title description idempotency inputSchema outputSchema]
|
|
37
|
+
),
|
|
38
|
+
'probes' => capabilities(
|
|
39
|
+
data.fetch('probes'),
|
|
40
|
+
%w[id version title description readOnly inputSchema outputSchema]
|
|
41
|
+
),
|
|
42
|
+
'sinks' => capabilities(
|
|
43
|
+
data.fetch('sinks'),
|
|
44
|
+
%w[id version title description effectKinds outcomes queryInputSchema recordSchema]
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
source = canonical_json(snapshot)
|
|
48
|
+
raise Error, 'Stable testing-control discovery exceeds its artifact boundary' if source.bytesize > MAX_BYTES
|
|
49
|
+
|
|
50
|
+
Result.new(snapshot:, digest: Digest::SHA256.hexdigest(source), bytes: source.bytesize)
|
|
51
|
+
rescue KeyError => e
|
|
52
|
+
raise Error, "Validated testing-control discovery is incomplete: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def canonical_json(value)
|
|
56
|
+
normalized = case value
|
|
57
|
+
when Hash
|
|
58
|
+
value.keys.sort.to_h { |key| [key, JSON.parse(canonical_json(value.fetch(key)))] }
|
|
59
|
+
when Array
|
|
60
|
+
value.map { |item| JSON.parse(canonical_json(item)) }
|
|
61
|
+
else
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
JSON.generate(normalized)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def capabilities(items, keys)
|
|
68
|
+
selected_items = items.map do |item|
|
|
69
|
+
selected = item.slice(*keys)
|
|
70
|
+
selected['effectKinds'] = selected.fetch('effectKinds').sort if selected.key?('effectKinds')
|
|
71
|
+
selected['outcomes'] = selected.fetch('outcomes').sort if selected.key?('outcomes')
|
|
72
|
+
selected
|
|
73
|
+
end
|
|
74
|
+
selected_items.sort_by { |item| [item.fetch('id'), item.fetch('version')] }
|
|
75
|
+
end
|
|
76
|
+
private_class_method :capabilities
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module JsonSubsetMatcher
|
|
6
|
+
Result = Data.define(:matched, :mismatch) do
|
|
7
|
+
def matched?
|
|
8
|
+
matched
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_h
|
|
12
|
+
{
|
|
13
|
+
'matched' => matched,
|
|
14
|
+
**(mismatch ? { 'mismatch' => mismatch } : {})
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(expected:, actual:)
|
|
21
|
+
mismatch = compare(expected, actual, '')
|
|
22
|
+
Result.new(matched: mismatch.nil?, mismatch: mismatch&.freeze)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def compare(expected, actual, path)
|
|
28
|
+
return compare_object(expected, actual, path) if expected.is_a?(Hash)
|
|
29
|
+
return compare_array(expected, actual, path) if expected.is_a?(Array)
|
|
30
|
+
return if expected == actual
|
|
31
|
+
|
|
32
|
+
mismatch(
|
|
33
|
+
reason: json_kind(expected) == json_kind(actual) ? 'value-mismatch' : 'type-mismatch',
|
|
34
|
+
path:,
|
|
35
|
+
expected_kind: json_kind(expected),
|
|
36
|
+
actual_kind: json_kind(actual)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def compare_object(expected, actual, path)
|
|
41
|
+
return type_mismatch(expected, actual, path) unless actual.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
expected.keys.sort.each do |key|
|
|
44
|
+
child_path = pointer(path, key)
|
|
45
|
+
unless actual.key?(key)
|
|
46
|
+
return mismatch(
|
|
47
|
+
reason: 'missing-key',
|
|
48
|
+
path: child_path,
|
|
49
|
+
expected_kind: json_kind(expected.fetch(key)),
|
|
50
|
+
actual_kind: 'missing'
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
finding = compare(expected.fetch(key), actual.fetch(key), child_path)
|
|
55
|
+
return finding if finding
|
|
56
|
+
end
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def compare_array(expected, actual, path)
|
|
61
|
+
return type_mismatch(expected, actual, path) unless actual.is_a?(Array)
|
|
62
|
+
unless expected.length == actual.length
|
|
63
|
+
return mismatch(
|
|
64
|
+
reason: 'array-length-mismatch',
|
|
65
|
+
path:,
|
|
66
|
+
expected_kind: 'array',
|
|
67
|
+
actual_kind: 'array',
|
|
68
|
+
lengths: {
|
|
69
|
+
'expectedLength' => expected.length,
|
|
70
|
+
'actualLength' => actual.length
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
expected.each_index do |index|
|
|
76
|
+
finding = compare(expected.fetch(index), actual.fetch(index), pointer(path, index))
|
|
77
|
+
return finding if finding
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def type_mismatch(expected, actual, path)
|
|
83
|
+
mismatch(
|
|
84
|
+
reason: 'type-mismatch',
|
|
85
|
+
path:,
|
|
86
|
+
expected_kind: json_kind(expected),
|
|
87
|
+
actual_kind: json_kind(actual)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def mismatch(reason:, path:, expected_kind:, actual_kind:, lengths: {})
|
|
92
|
+
{
|
|
93
|
+
'reason' => reason,
|
|
94
|
+
'path' => path,
|
|
95
|
+
'expectedKind' => expected_kind,
|
|
96
|
+
'actualKind' => actual_kind,
|
|
97
|
+
**lengths
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def pointer(path, token)
|
|
102
|
+
escaped = token.to_s.gsub('~', '~0').gsub('/', '~1')
|
|
103
|
+
"#{path}/#{escaped}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def json_kind(value)
|
|
107
|
+
case value
|
|
108
|
+
when Hash then 'object'
|
|
109
|
+
when Array then 'array'
|
|
110
|
+
when String then 'string'
|
|
111
|
+
when Numeric then 'number'
|
|
112
|
+
when TrueClass, FalseClass then 'boolean'
|
|
113
|
+
when NilClass then 'null'
|
|
114
|
+
else 'unknown'
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'json_schemer'
|
|
6
|
+
|
|
7
|
+
module Overseer
|
|
8
|
+
module TestingControl
|
|
9
|
+
module ProtocolV3
|
|
10
|
+
NAME = 'overseer-testing-control'
|
|
11
|
+
VERSION = '3'
|
|
12
|
+
PACKAGE_VERSION = '0.1.0'
|
|
13
|
+
SCHEMA_SHA256 = '9e0591133fd38c15e96383eae11b202cb27ea8f7ddc98d2a64f2abff326cb924'
|
|
14
|
+
CAPABILITY_PATTERN = /\A[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\z/
|
|
15
|
+
CAPABILITY_VERSION_PATTERN = /\A[1-9][0-9]{0,31}\z/
|
|
16
|
+
JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
|
|
17
|
+
IDENTIFIER_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
|
|
18
|
+
REQUIRED_FEATURES = %w[
|
|
19
|
+
correlation-propagation
|
|
20
|
+
deterministic-replay
|
|
21
|
+
reset
|
|
22
|
+
schema-validation
|
|
23
|
+
sink-clearing
|
|
24
|
+
].freeze
|
|
25
|
+
HEADERS = {
|
|
26
|
+
version: 'Overseer-Testing-Control-Version',
|
|
27
|
+
run_id: 'Overseer-Run-Id',
|
|
28
|
+
correlation_id: 'Overseer-Correlation-Id',
|
|
29
|
+
step_id: 'Overseer-Step-Id',
|
|
30
|
+
idempotency_key: 'Overseer-Idempotency-Key'
|
|
31
|
+
}.freeze
|
|
32
|
+
PATHS = {
|
|
33
|
+
capabilities: '/capabilities',
|
|
34
|
+
reset: '/reset',
|
|
35
|
+
states: '/states',
|
|
36
|
+
probes: '/probes',
|
|
37
|
+
sink_query: '/sinks/query'
|
|
38
|
+
}.freeze
|
|
39
|
+
SCHEMA_PATH = File.expand_path(
|
|
40
|
+
'../../../protocol/testing-control/v3/schema.json',
|
|
41
|
+
__dir__
|
|
42
|
+
)
|
|
43
|
+
OPENAPI_PATH = File.join(File.dirname(SCHEMA_PATH), 'openapi.yaml')
|
|
44
|
+
DOCUMENT_DEFINITIONS = {
|
|
45
|
+
capabilities: 'capabilitiesResponse',
|
|
46
|
+
reset: 'resetResponse',
|
|
47
|
+
state_request: 'stateRequest',
|
|
48
|
+
state_response: 'stateResponse',
|
|
49
|
+
probe_request: 'probeRequest',
|
|
50
|
+
probe_response: 'probeResponse',
|
|
51
|
+
sink_query_request: 'sinkQueryRequest',
|
|
52
|
+
sink_query_response: 'sinkQueryResponse',
|
|
53
|
+
error: 'errorResponse'
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
def protocol
|
|
59
|
+
{ 'name' => NAME, 'version' => VERSION }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def meta(run_id:, correlation_id:, step_id: nil)
|
|
63
|
+
{
|
|
64
|
+
'runId' => run_id,
|
|
65
|
+
'correlationId' => correlation_id,
|
|
66
|
+
**(step_id ? { 'stepId' => step_id } : {})
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def envelope(data:, **identity)
|
|
71
|
+
{ 'protocol' => protocol, 'meta' => meta(**identity), 'data' => data }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def error_envelope(errors:, **identity)
|
|
75
|
+
{ 'protocol' => protocol, 'meta' => meta(**identity), 'errors' => errors }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_schema!(schema, label:, max_bytes:)
|
|
80
|
+
bytes = JSON.generate(schema).bytesize
|
|
81
|
+
raise ValidationError, "#{label} schema exceeds #{max_bytes} bytes" if bytes > max_bytes
|
|
82
|
+
unless schema.is_a?(Hash) && schema['$schema'] == JSON_SCHEMA_DIALECT && schema['type'] == 'object'
|
|
83
|
+
raise ValidationError, "#{label} must be a Draft 2020-12 schema with an object root"
|
|
84
|
+
end
|
|
85
|
+
raise ValidationError, "#{label} schema contains a non-local reference" unless only_local_references?(schema)
|
|
86
|
+
|
|
87
|
+
errors = JSONSchemer.validate_schema(schema).take(3)
|
|
88
|
+
raise ValidationError, "#{label} schema is invalid: #{error_details(errors)}" unless errors.empty?
|
|
89
|
+
|
|
90
|
+
JSONSchemer.schema(schema).validate({}).to_a
|
|
91
|
+
schema
|
|
92
|
+
rescue JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer => e
|
|
93
|
+
raise ValidationError, "#{label} schema cannot be compiled: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def capability_id!(value, label:)
|
|
98
|
+
return value if value.is_a?(String) && value.bytesize <= 96 && value.match?(CAPABILITY_PATTERN)
|
|
99
|
+
|
|
100
|
+
raise ValidationError, "#{label} must be a valid capability identifier"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def capability_version!(value, label:)
|
|
104
|
+
return value if value.is_a?(String) && value.match?(CAPABILITY_VERSION_PATTERN)
|
|
105
|
+
|
|
106
|
+
raise ValidationError, "#{label} must be a positive decimal version"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def application_version!(value)
|
|
110
|
+
return value if value.is_a?(String) && value.bytesize.between?(1, 128)
|
|
111
|
+
|
|
112
|
+
raise ValidationError, 'application source identity must be between 1 and 128 bytes'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def timestamp
|
|
116
|
+
Time.now.utc.iso8601(3)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stringify(value)
|
|
120
|
+
case value
|
|
121
|
+
when Hash
|
|
122
|
+
value.to_h { |key, child| [key.to_s, stringify(child)] }
|
|
123
|
+
when Array
|
|
124
|
+
value.map { |child| stringify(child) }
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_document!(kind, value)
|
|
132
|
+
errors = document_schemer(kind).validate(value).take(3)
|
|
133
|
+
return value if errors.empty?
|
|
134
|
+
|
|
135
|
+
raise Error, "Invalid protocol v3 #{kind} response: #{error_details(errors)}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_response_headers!(headers, correlation_id:)
|
|
139
|
+
version = header_value(headers, HEADERS.fetch(:version))
|
|
140
|
+
correlation = header_value(headers, HEADERS.fetch(:correlation_id))
|
|
141
|
+
raise Error, 'Protocol v3 response version header must equal 3' unless version == VERSION
|
|
142
|
+
raise Error, 'Protocol v3 response correlation header is invalid' unless
|
|
143
|
+
correlation&.match?(IDENTIFIER_PATTERN)
|
|
144
|
+
raise Error, 'Protocol v3 response correlation does not match the request' unless
|
|
145
|
+
correlation == correlation_id
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_meta!(document, run_id:, correlation_id:, step_id: nil)
|
|
149
|
+
meta = document.fetch('meta')
|
|
150
|
+
return if meta['runId'] == run_id &&
|
|
151
|
+
meta['correlationId'] == correlation_id &&
|
|
152
|
+
meta['stepId'] == step_id &&
|
|
153
|
+
meta.key?('stepId') == !step_id.nil?
|
|
154
|
+
|
|
155
|
+
raise Error, 'Protocol v3 response metadata does not match the request identity'
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_capabilities!(document, required:)
|
|
159
|
+
validate_document!(:capabilities, document)
|
|
160
|
+
data = document.fetch('data')
|
|
161
|
+
validate_required_features!(data.fetch('features'))
|
|
162
|
+
validate_capability_family!('state', data.fetch('states'), data.dig('limits', 'maxSchemaBytes')) do |item|
|
|
163
|
+
[item.fetch('inputSchema'), item.fetch('outputSchema')]
|
|
164
|
+
end
|
|
165
|
+
validate_capability_family!('probe', data.fetch('probes'), data.dig('limits', 'maxSchemaBytes')) do |item|
|
|
166
|
+
[item.fetch('inputSchema'), item.fetch('outputSchema')]
|
|
167
|
+
end
|
|
168
|
+
validate_capability_family!('sink', data.fetch('sinks'), data.dig('limits', 'maxSchemaBytes')) do |item|
|
|
169
|
+
[item.fetch('queryInputSchema'), item.fetch('recordSchema')]
|
|
170
|
+
end
|
|
171
|
+
validate_required_capabilities!(data, required)
|
|
172
|
+
document
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_value!(schema, value, label:)
|
|
176
|
+
errors = JSONSchemer.schema(schema).validate(value).take(3)
|
|
177
|
+
return value if errors.empty?
|
|
178
|
+
|
|
179
|
+
raise Error, "#{label} does not satisfy its advertised schema: #{error_details(errors)}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def identifier!(value, label:)
|
|
183
|
+
return value if value.is_a?(String) && value.match?(IDENTIFIER_PATTERN)
|
|
184
|
+
|
|
185
|
+
raise Error, "#{label} must be a valid opaque identifier"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def document_schemer(kind)
|
|
189
|
+
definition = DOCUMENT_DEFINITIONS.fetch(kind)
|
|
190
|
+
@document_schemers ||= {}
|
|
191
|
+
@document_schemers[definition] ||= root_schemer.ref("#/$defs/#{definition}")
|
|
192
|
+
end
|
|
193
|
+
private_class_method :document_schemer
|
|
194
|
+
|
|
195
|
+
def root_schemer
|
|
196
|
+
@root_schemer ||= JSONSchemer.schema(JSON.parse(File.read(SCHEMA_PATH, encoding: 'UTF-8')))
|
|
197
|
+
end
|
|
198
|
+
private_class_method :root_schemer
|
|
199
|
+
|
|
200
|
+
def validate_required_features!(features)
|
|
201
|
+
missing = REQUIRED_FEATURES - features
|
|
202
|
+
return if missing.empty?
|
|
203
|
+
|
|
204
|
+
raise Error, "Protocol v3 capabilities omit required features: #{missing.join(', ')}"
|
|
205
|
+
end
|
|
206
|
+
private_class_method :validate_required_features!
|
|
207
|
+
|
|
208
|
+
def validate_capability_family!(kind, capabilities, max_schema_bytes)
|
|
209
|
+
seen = {}
|
|
210
|
+
capabilities.each do |capability|
|
|
211
|
+
key = "#{capability.fetch('id')}@#{capability.fetch('version')}"
|
|
212
|
+
raise Error, "Duplicate #{kind} capability #{key}" if seen[key]
|
|
213
|
+
|
|
214
|
+
seen[key] = true
|
|
215
|
+
yield(capability).each do |schema|
|
|
216
|
+
validate_advertised_schema!(schema, label: "#{kind} #{key}", max_bytes: max_schema_bytes)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
private_class_method :validate_capability_family!
|
|
221
|
+
|
|
222
|
+
def validate_advertised_schema!(schema, label:, max_bytes:)
|
|
223
|
+
bytes = JSON.generate(schema).bytesize
|
|
224
|
+
raise Error, "#{label} schema exceeds maxSchemaBytes #{max_bytes}" if bytes > max_bytes
|
|
225
|
+
raise Error, "#{label} schema contains a non-local reference" unless only_local_references?(schema)
|
|
226
|
+
|
|
227
|
+
errors = JSONSchemer.validate_schema(schema).take(3)
|
|
228
|
+
raise Error, "#{label} schema is invalid: #{error_details(errors)}" unless errors.empty?
|
|
229
|
+
|
|
230
|
+
compile_schema!(schema, label:)
|
|
231
|
+
end
|
|
232
|
+
private_class_method :validate_advertised_schema!
|
|
233
|
+
|
|
234
|
+
def only_local_references?(value)
|
|
235
|
+
references(value).all? { |reference| reference.is_a?(String) && reference.start_with?('#') }
|
|
236
|
+
end
|
|
237
|
+
private_class_method :only_local_references?
|
|
238
|
+
|
|
239
|
+
def references(value)
|
|
240
|
+
case value
|
|
241
|
+
when Hash
|
|
242
|
+
own = value.filter_map do |key, child|
|
|
243
|
+
child if %w[$ref $dynamicRef].include?(key)
|
|
244
|
+
end
|
|
245
|
+
own + value.values.flat_map { |child| references(child) }
|
|
246
|
+
when Array
|
|
247
|
+
value.flat_map { |child| references(child) }
|
|
248
|
+
else
|
|
249
|
+
[]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
private_class_method :references
|
|
253
|
+
|
|
254
|
+
def compile_schema!(schema, label:)
|
|
255
|
+
JSONSchemer.schema(schema).validate({}).to_a
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
raise Error, "#{label} schema cannot be compiled: #{e.message}"
|
|
258
|
+
end
|
|
259
|
+
private_class_method :compile_schema!
|
|
260
|
+
|
|
261
|
+
def validate_required_capabilities!(data, required)
|
|
262
|
+
{
|
|
263
|
+
'state' => ['states', required.fetch('states')],
|
|
264
|
+
'probe' => ['probes', required.fetch('probes')],
|
|
265
|
+
'sink' => ['sinks', required.fetch('sinks')]
|
|
266
|
+
}.each do |kind, (family, expected)|
|
|
267
|
+
advertised = data.fetch(family)
|
|
268
|
+
expected.each do |id, version|
|
|
269
|
+
next if advertised.any? do |capability|
|
|
270
|
+
capability['id'] == id && capability['version'] == version
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
raise Error, "Required testing #{kind} #{id}@#{version} is unavailable"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
private_class_method :validate_required_capabilities!
|
|
278
|
+
|
|
279
|
+
def header_value(headers, name)
|
|
280
|
+
headers.each do |candidate, value|
|
|
281
|
+
return Array(value).first if candidate.to_s.casecmp?(name)
|
|
282
|
+
end
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
private_class_method :header_value
|
|
286
|
+
|
|
287
|
+
def error_details(errors)
|
|
288
|
+
errors.map do |error|
|
|
289
|
+
"#{error.fetch('data_pointer', '/')} #{error.fetch('type')}"
|
|
290
|
+
end.join('; ')
|
|
291
|
+
end
|
|
292
|
+
private_class_method :error_details
|
|
293
|
+
|
|
294
|
+
class Error < StandardError; end
|
|
295
|
+
ValidationError = Error
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Overseer
|
|
6
|
+
module TestingControl
|
|
7
|
+
module Redaction
|
|
8
|
+
REDACTED = '<redacted>'
|
|
9
|
+
REDACTED_EMAIL = '<redacted-email>'
|
|
10
|
+
JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/
|
|
11
|
+
BEARER_PATTERN = %r{\bBearer\s+[A-Za-z0-9._~+/-]+=*}i
|
|
12
|
+
EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i
|
|
13
|
+
|
|
14
|
+
Policy = Data.define(:header_names, :field_patterns, :max_body_bytes, :exact_values) do
|
|
15
|
+
def initialize(exact_values: [], **values)
|
|
16
|
+
safe = exact_values.select do |value|
|
|
17
|
+
value.is_a?(String) && !value.empty?
|
|
18
|
+
end
|
|
19
|
+
safe = safe.uniq.sort_by { |value| -value.bytesize }
|
|
20
|
+
super(**values, exact_values: safe.freeze)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_config(config, exact_values: [])
|
|
24
|
+
new(
|
|
25
|
+
header_names: config.fetch('headerNames').map(&:downcase).freeze,
|
|
26
|
+
field_patterns: config.fetch('fieldPatterns').map(&:downcase).freeze,
|
|
27
|
+
max_body_bytes: config.fetch('maxBodyBytes'),
|
|
28
|
+
exact_values:
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def with_exact_values(values)
|
|
33
|
+
self.class.new(
|
|
34
|
+
header_names:,
|
|
35
|
+
field_patterns:,
|
|
36
|
+
max_body_bytes:,
|
|
37
|
+
exact_values: exact_values + Array(values)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def value(value, policy, key: '')
|
|
45
|
+
return REDACTED if sensitive_key?(key, policy)
|
|
46
|
+
|
|
47
|
+
case value
|
|
48
|
+
when String
|
|
49
|
+
string(value, policy)
|
|
50
|
+
when Array
|
|
51
|
+
value.map { |item| self.value(item, policy) }
|
|
52
|
+
when Hash
|
|
53
|
+
value.to_h do |entry_key, entry_value|
|
|
54
|
+
[safe_hash_key(entry_key, policy), self.value(entry_value, policy, key: entry_key.to_s)]
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def string(value, policy = nil)
|
|
62
|
+
safe = value
|
|
63
|
+
.gsub(JWT_PATTERN, REDACTED)
|
|
64
|
+
.gsub(BEARER_PATTERN, "Bearer #{REDACTED}")
|
|
65
|
+
.gsub(EMAIL_PATTERN) do |email|
|
|
66
|
+
email.downcase.end_with?('.invalid') ? email : REDACTED_EMAIL
|
|
67
|
+
end
|
|
68
|
+
Array(policy&.exact_values).reduce(safe) { |text, exact| text.gsub(exact, '<redacted-generated-secret>') }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def headers(headers, policy)
|
|
72
|
+
headers.to_h do |name, value|
|
|
73
|
+
normalized_name = name.to_s.downcase
|
|
74
|
+
normalized_value = Array(value).join(', ')
|
|
75
|
+
safe_name = string(normalized_name, policy)
|
|
76
|
+
safe_value = if policy.header_names.include?(normalized_name)
|
|
77
|
+
REDACTED
|
|
78
|
+
else
|
|
79
|
+
string(normalized_value, policy)
|
|
80
|
+
end
|
|
81
|
+
[safe_name, safe_value]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def body(value, policy)
|
|
86
|
+
safe = self.value(value, policy)
|
|
87
|
+
serialized = JSON.generate(safe)
|
|
88
|
+
return safe if serialized.bytesize <= policy.max_body_bytes
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
'truncated' => true,
|
|
92
|
+
'originalBytes' => serialized.bytesize,
|
|
93
|
+
'retainedBytes' => policy.max_body_bytes,
|
|
94
|
+
'preview' => string(serialized.byteslice(0, policy.max_body_bytes).to_s.scrub, policy)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def sensitive_key?(key, policy)
|
|
99
|
+
normalized = key.downcase
|
|
100
|
+
policy.field_patterns.each { |pattern| return true if normalized.include?(pattern) }
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
private_class_method :sensitive_key?
|
|
104
|
+
|
|
105
|
+
def safe_hash_key(key, policy)
|
|
106
|
+
key.is_a?(String) ? string(key, policy) : key
|
|
107
|
+
end
|
|
108
|
+
private_class_method :safe_hash_key
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'testing_control/protocol_v3'
|
|
4
|
+
require_relative 'testing_control/discovery'
|
|
5
|
+
require_relative 'testing_control/redaction'
|
|
6
|
+
require_relative 'testing_control/conformance/case_file'
|
|
7
|
+
require_relative 'testing_control/conformance/http_transport'
|
|
8
|
+
require_relative 'testing_control/conformance/report'
|
|
9
|
+
require_relative 'testing_control/conformance/runner'
|