haveapi-client 0.30.2 → 0.31.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 +4 -4
- data/lib/haveapi/client/version.rb +1 -1
- data/lib/haveapi/schema/capture.rb +81 -0
- data/lib/haveapi/schema/fetch_command.rb +78 -0
- data/lib/haveapi/schema/snapshot.rb +203 -0
- data/lib/haveapi/schema.rb +26 -0
- data/spec/schema_capture_spec.rb +101 -0
- data/spec/schema_capture_wire.rb +128 -0
- data/spec/schema_fetch_command_spec.rb +102 -0
- data/spec/schema_snapshot_spec.rb +122 -0
- data/spec/schema_spec_helper.rb +27 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a038ddc787fd511c462c3726c76879d2e681e5db46bb4ea45c9892a7827f9d2e
|
|
4
|
+
data.tar.gz: ca3e1618fbb4a2e1b072d8840d485043ce4b3b3642cdc2e90b5360246e8ed185
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1473dc78ee53f54123b94354190977141fe1f9888f11d401ec278bf09ae695c4b9ae5766503af504bd95cce19027d3a790eaae3444e4ed28b0038b3124716be2
|
|
7
|
+
data.tar.gz: cf0f6f0aeb4fd55c9f7053d32db1b273de8a903d9c371be75d086ed180e314ba7b3bbcbfe2e32be3dac4cf5a1d3454b8d3e35377ee388983f3fde313dca3d3bb
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module HaveAPI::Schema
|
|
2
|
+
module Capture
|
|
3
|
+
def self.fetch(url:, api_version:, profile:, basic_user: nil, basic_password: nil, token: nil, diagnostics: [])
|
|
4
|
+
mount = validate_options(url: url, api_version: api_version, profile: profile,
|
|
5
|
+
basic_user: basic_user, basic_password: basic_password, token: token)
|
|
6
|
+
begin
|
|
7
|
+
require 'rest-client'
|
|
8
|
+
rescue LoadError
|
|
9
|
+
raise Error, 'capture dependencies are unavailable; prepare the haveapi-client bundle', cause: nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
headers = { accept: :json }
|
|
13
|
+
if token
|
|
14
|
+
discovery = snapshot(request(url, headers), url, api_version, profile, mount, [])
|
|
15
|
+
provider = discovery.fetch('description').fetch('authentication')['token']
|
|
16
|
+
raise Error, 'API does not advertise HaveAPI token authentication' unless provider.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
headers = headers.merge(token_headers(provider, token))
|
|
19
|
+
end
|
|
20
|
+
snapshot(request(url, headers, basic_user, basic_password), url, api_version, profile, mount, diagnostics)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.validate_options(url:, api_version:, profile:, basic_user: nil, basic_password: nil, token: nil)
|
|
24
|
+
Snapshot.version!(api_version)
|
|
25
|
+
endpoint = Snapshot.endpoint!(url)
|
|
26
|
+
suffix = "v#{api_version}/"
|
|
27
|
+
unless endpoint.path.end_with?("/#{suffix}")
|
|
28
|
+
raise Error, 'fetch URL must select the exact requested version'
|
|
29
|
+
end
|
|
30
|
+
raise Error, 'profile must be a nonempty label' unless profile.is_a?(String) && !profile.strip.empty?
|
|
31
|
+
if !token.nil? && (!basic_user.nil? || !basic_password.nil?)
|
|
32
|
+
raise Error, 'Basic and token authentication are mutually exclusive'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if (!basic_user.nil? || !basic_password.nil?) && !(credential?(basic_user) && !basic_user.include?(':') && credential?(basic_password))
|
|
36
|
+
raise Error, 'Basic authentication requires a valid user and password'
|
|
37
|
+
end
|
|
38
|
+
raise Error, 'existing token must be a nonempty header value' if !token.nil? && !credential?(token)
|
|
39
|
+
|
|
40
|
+
endpoint.path.delete_suffix(suffix)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.credential?(value)
|
|
44
|
+
value.is_a?(String) && !value.empty? && !value.match?(/[\x00-\x1f\x7f]/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.request(url, headers, basic_user = nil, basic_password = nil)
|
|
48
|
+
options = {
|
|
49
|
+
method: :options, url: url, headers: headers, verify_ssl: true,
|
|
50
|
+
max_redirects: 0, open_timeout: 10, read_timeout: 30
|
|
51
|
+
}
|
|
52
|
+
options.update(user: basic_user, password: basic_password) if basic_user
|
|
53
|
+
response = RestClient::Request.execute(options)
|
|
54
|
+
unless response.code >= 200 && response.code < 300
|
|
55
|
+
raise Error, 'metadata acquisition failed (HTTP or redirect error)'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
JSON.parse(response.to_s)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
raise Error, 'metadata acquisition failed (HTTP, redirect or response error)', cause: nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.snapshot(envelope, url, version, profile, mount, diagnostics)
|
|
64
|
+
Snapshot.from_envelope(envelope, api_version: version, source_url: url,
|
|
65
|
+
profile: profile, mount_path: mount, diagnostics: diagnostics)
|
|
66
|
+
rescue Error, ArgumentError, TypeError, NoMethodError
|
|
67
|
+
raise Error, 'metadata response is not a valid successful protocol 2 description for the requested version', cause: nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.token_headers(provider, token)
|
|
71
|
+
# Supplying a token prevents this existing provider from acquiring or renewing one.
|
|
72
|
+
require 'haveapi/client'
|
|
73
|
+
HaveAPI::Client::Authentication::Token.new(nil, { http_header: provider['http_header'] }, token: token).request_headers
|
|
74
|
+
rescue LoadError
|
|
75
|
+
raise Error, 'capture dependencies are unavailable; prepare the haveapi-client bundle', cause: nil
|
|
76
|
+
rescue StandardError
|
|
77
|
+
raise Error, 'API advertises an invalid HaveAPI token HTTP header', cause: nil
|
|
78
|
+
end
|
|
79
|
+
private_class_method :credential?, :request, :snapshot, :token_headers
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'optparse'
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
|
|
4
|
+
module HaveAPI::Schema
|
|
5
|
+
module FetchCommand
|
|
6
|
+
def self.run(argv, command_name:, out: $stdout, err: $stderr)
|
|
7
|
+
options = {}
|
|
8
|
+
args = argv.dup
|
|
9
|
+
parser = OptionParser.new do |opts|
|
|
10
|
+
opts.banner = "Usage: #{command_name} fetch URL --api-version VERSION --profile LABEL --output FILE"
|
|
11
|
+
opts.on('--api-version VERSION') { |v| options[:api_version] = v }
|
|
12
|
+
opts.on('--profile LABEL') { |v| options[:profile] = v }
|
|
13
|
+
opts.on('--output FILE') { |v| options[:output] = v }
|
|
14
|
+
opts.on('--basic-user USER') { |v| options[:basic_user] = v }
|
|
15
|
+
opts.on('--basic-password-env ENV_NAME') { |v| options[:password_env] = v }
|
|
16
|
+
opts.on('--token-env ENV_NAME') { |v| options[:token_env] = v }
|
|
17
|
+
opts.on('-h', '--help') do
|
|
18
|
+
out.puts(opts)
|
|
19
|
+
return 0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
parser.parse!(args)
|
|
23
|
+
raise Error, 'fetch requires one URL, --api-version, --profile and --output' unless args.length == 1 && options[:output]
|
|
24
|
+
if options[:token_env] && (options[:basic_user] || options[:password_env])
|
|
25
|
+
raise Error, 'Basic and token authentication are mutually exclusive'
|
|
26
|
+
end
|
|
27
|
+
if options[:basic_user].nil? != options[:password_env].nil?
|
|
28
|
+
raise Error, 'Basic authentication requires --basic-user and --basic-password-env'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
request = { url: args.first, api_version: options[:api_version], profile: options[:profile],
|
|
32
|
+
basic_user: options[:basic_user], basic_password: secret(options[:password_env]), token: secret(options[:token_env]) }
|
|
33
|
+
Capture.validate_options(**request)
|
|
34
|
+
output = File.expand_path(options[:output])
|
|
35
|
+
validate_output(output)
|
|
36
|
+
diagnostics = []
|
|
37
|
+
value = Capture.fetch(**request, diagnostics: diagnostics)
|
|
38
|
+
bytes = Snapshot.canonical(Snapshot.validate(value))
|
|
39
|
+
validate_output(output)
|
|
40
|
+
Tempfile.create(['.haveapi-schema-', '.json'], File.dirname(output)) do |temp|
|
|
41
|
+
temp.binmode
|
|
42
|
+
temp.write(bytes)
|
|
43
|
+
temp.close
|
|
44
|
+
File.rename(temp.path, output)
|
|
45
|
+
end
|
|
46
|
+
diagnostics.each { |message| err.puts(message) }
|
|
47
|
+
0
|
|
48
|
+
rescue OptionParser::ParseError
|
|
49
|
+
err.puts("#{command_name}: invalid fetch arguments; use --help")
|
|
50
|
+
1
|
|
51
|
+
rescue Error => e
|
|
52
|
+
err.puts("#{command_name}: #{e.message}")
|
|
53
|
+
1
|
|
54
|
+
rescue SystemCallError, IOError, ArgumentError, TypeError
|
|
55
|
+
err.puts("#{command_name}: unable to read or save the snapshot")
|
|
56
|
+
1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.secret(name)
|
|
60
|
+
return nil if name.nil?
|
|
61
|
+
unless name.is_a?(String) && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
62
|
+
raise Error, 'credential environment variable name is invalid'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
value = ENV.fetch(name, nil)
|
|
66
|
+
raise Error, 'credential environment variable is missing or empty' unless value && !value.empty?
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.validate_output(path)
|
|
72
|
+
raise Error, 'snapshot output cannot be a symlink' if File.symlink?(path)
|
|
73
|
+
raise Error, 'snapshot output parent must already exist' unless File.directory?(File.dirname(path))
|
|
74
|
+
raise Error, 'snapshot output must be a regular file' if File.exist?(path) && !File.file?(path)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :secret, :validate_output
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module HaveAPI::Schema
|
|
6
|
+
module Snapshot
|
|
7
|
+
KEYS = %w[schema_format api_version protocol_version source_url profile mount_path description].freeze
|
|
8
|
+
PROSE = %w[description label examples message comment title].freeze
|
|
9
|
+
|
|
10
|
+
def self.canonical(value)
|
|
11
|
+
"#{JSON.pretty_generate(canonical_value(value))}\n"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.canonical_value(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.keys.sort.to_h { |key| [key, canonical_value(value.fetch(key))] }
|
|
18
|
+
when Array
|
|
19
|
+
value.map { |item| canonical_value(item) }
|
|
20
|
+
else
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.sha256(snapshot)
|
|
26
|
+
Digest::SHA256.hexdigest(canonical(snapshot))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.load(path)
|
|
30
|
+
validate(JSON.parse(File.read(path)))
|
|
31
|
+
rescue JSON::ParserError, EncodingError
|
|
32
|
+
raise Error, 'snapshot is not valid UTF-8 JSON', cause: nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.from_envelope(envelope, api_version:, source_url:, profile:, mount_path: '/', diagnostics: [])
|
|
36
|
+
unless envelope.is_a?(Hash) && envelope['status'] == true
|
|
37
|
+
raise Error, 'metadata envelope is not successful'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
from_description(
|
|
41
|
+
description: envelope.fetch('response'), protocol_version: envelope.fetch('version'),
|
|
42
|
+
api_version: api_version, source_url: source_url, profile: profile,
|
|
43
|
+
mount_path: mount_path, diagnostics: diagnostics
|
|
44
|
+
)
|
|
45
|
+
rescue KeyError
|
|
46
|
+
raise Error, 'metadata envelope is incomplete'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.from_description(description:, api_version:, protocol_version:, source_url:, profile:, mount_path: '/', diagnostics: [])
|
|
50
|
+
validate({
|
|
51
|
+
'schema_format' => 1, 'api_version' => api_version, 'protocol_version' => protocol_version,
|
|
52
|
+
'source_url' => source_url, 'profile' => profile, 'mount_path' => mount_path,
|
|
53
|
+
'description' => sanitize(description, diagnostics: diagnostics)
|
|
54
|
+
})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only structural prose is removed. Custom/default JSON and field names are data.
|
|
58
|
+
def self.sanitize(value, context = :description, diagnostics: [], path: [])
|
|
59
|
+
return value.map { |item| sanitize(item, context, diagnostics: diagnostics, path: path) } if value.is_a?(Array)
|
|
60
|
+
return value unless value.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
value.each_with_object({}) do |(key, item), result|
|
|
63
|
+
next if PROSE.include?(key) && !%i[map data choices].include?(context)
|
|
64
|
+
|
|
65
|
+
machine_endpoint!(key, item, path + [key]) if context == :description
|
|
66
|
+
|
|
67
|
+
if context == :description && key == 'default' && value['protected'] == true && !item.nil?
|
|
68
|
+
diagnostics << "removed protected default: #{path.join('/')}"
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
child = child_context(context, key, item)
|
|
73
|
+
result[key] = if context == :validators && key == 'custom'
|
|
74
|
+
true
|
|
75
|
+
elsif context == :choices
|
|
76
|
+
nil
|
|
77
|
+
else
|
|
78
|
+
sanitize(item, child, diagnostics: diagnostics, path: path + [key])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.child_context(context, key, item)
|
|
84
|
+
return :data if %i[data choices].include?(context)
|
|
85
|
+
return :description if context == :map
|
|
86
|
+
return :map if %w[resources actions parameters authentication].include?(key)
|
|
87
|
+
return :data if %w[default format].include?(key) && context != :validator
|
|
88
|
+
return item.is_a?(Hash) ? :choices : :data if key == 'values'
|
|
89
|
+
return :validators if key == 'validators'
|
|
90
|
+
return :validator if context == :validators
|
|
91
|
+
|
|
92
|
+
:description
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.machine_endpoint!(key, value, path)
|
|
96
|
+
return if value.nil?
|
|
97
|
+
|
|
98
|
+
if key.end_with?('_url')
|
|
99
|
+
endpoint!(value)
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
return unless %w[path help].include?(key) || key.end_with?('_path')
|
|
103
|
+
|
|
104
|
+
route, query = value.is_a?(String) ? value.split('?', 2) : [nil, nil]
|
|
105
|
+
unless route && safe_path?(route) &&
|
|
106
|
+
(query.nil? || (key == 'help' && query.match?(/\Amethod=(?:GET|POST|PUT|PATCH|DELETE|OPTIONS)\z/)))
|
|
107
|
+
raise Error, 'unsafe machine endpoint'
|
|
108
|
+
end
|
|
109
|
+
rescue Error
|
|
110
|
+
raise Error, "unsafe machine endpoint at #{path.join('/')}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.validate_machine_endpoints(value, context = :description, path = [])
|
|
114
|
+
if value.is_a?(Array)
|
|
115
|
+
value.each { |item| validate_machine_endpoints(item, context, path) }
|
|
116
|
+
elsif value.is_a?(Hash)
|
|
117
|
+
value.each do |key, item|
|
|
118
|
+
machine_endpoint!(key, item, path + [key]) if context == :description
|
|
119
|
+
validate_machine_endpoints(item, child_context(context, key, item), path + [key])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.validate(snapshot)
|
|
125
|
+
raise Error, 'snapshot must be an object' unless snapshot.is_a?(Hash)
|
|
126
|
+
raise Error, 'unsupported snapshot envelope keys' unless snapshot.keys.sort == KEYS.sort
|
|
127
|
+
raise Error, 'unsupported snapshot format' unless snapshot['schema_format'] == 1
|
|
128
|
+
|
|
129
|
+
version!(snapshot['api_version'])
|
|
130
|
+
unless snapshot['protocol_version'].is_a?(String) && snapshot['protocol_version'].match?(/\A2\.\d+\z/)
|
|
131
|
+
raise Error, 'unsupported HaveAPI protocol major'
|
|
132
|
+
end
|
|
133
|
+
unless snapshot['profile'].is_a?(String) && !snapshot['profile'].strip.empty?
|
|
134
|
+
raise Error, 'profile must be a nonempty label'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
endpoint = endpoint!(snapshot['source_url'])
|
|
138
|
+
mount = snapshot['mount_path']
|
|
139
|
+
unless mount.is_a?(String) && mount.start_with?('/') && mount.end_with?('/') && safe_path?(mount)
|
|
140
|
+
raise Error, 'invalid capture mount path'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
expected = "#{mount}v#{snapshot['api_version']}/"
|
|
144
|
+
raise Error, 'snapshot source URL/version mismatch' unless endpoint.path == expected
|
|
145
|
+
|
|
146
|
+
description = snapshot['description']
|
|
147
|
+
unless description.is_a?(Hash) && description['resources'].is_a?(Hash) &&
|
|
148
|
+
description['authentication'].is_a?(Hash) && description['meta'].is_a?(Hash)
|
|
149
|
+
raise Error, 'invalid API description structure'
|
|
150
|
+
end
|
|
151
|
+
raise Error, 'snapshot description/version mismatch' unless description['help'] == expected
|
|
152
|
+
|
|
153
|
+
description['authentication'].each do |provider, auth|
|
|
154
|
+
raise Error, "invalid authentication description at authentication/#{provider}" unless auth.is_a?(Hash)
|
|
155
|
+
end
|
|
156
|
+
validate_machine_endpoints(description)
|
|
157
|
+
snapshot
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.version!(version)
|
|
161
|
+
return if version.is_a?(String) && version.match?(/\A\d+(?:\.\d+)*\z/)
|
|
162
|
+
|
|
163
|
+
raise Error, 'an explicit numeric API version is required'
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.endpoint!(url)
|
|
167
|
+
raise Error, 'invalid endpoint URL' unless url.is_a?(String)
|
|
168
|
+
|
|
169
|
+
endpoint = URI.parse(url)
|
|
170
|
+
unless %w[http https].include?(endpoint.scheme) && endpoint.host && endpoint.userinfo.nil? &&
|
|
171
|
+
endpoint.query.nil? && endpoint.fragment.nil? && safe_path?(endpoint.path)
|
|
172
|
+
raise Error, 'endpoint must be an HTTP URL without credentials, query or fragment'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
endpoint
|
|
176
|
+
rescue URI::InvalidURIError, TypeError
|
|
177
|
+
raise Error, 'invalid endpoint URL', cause: nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Compatibility names used by existing generator facades.
|
|
181
|
+
def self.sanitization_context(context, key, item)
|
|
182
|
+
child_context(context, key, item)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.validate_machine_endpoint(key, value)
|
|
186
|
+
machine_endpoint!(key, value, [key])
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.safe_segments?(path)
|
|
190
|
+
return false unless path.is_a?(String)
|
|
191
|
+
|
|
192
|
+
safe_path?(path.start_with?('/') ? path : "/#{path}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.safe_path?(path)
|
|
196
|
+
path.is_a?(String) && path.start_with?('/') && !path.start_with?('//') && !path.match?(/[\\?#\x00-\x20\x7f]/) &&
|
|
197
|
+
path.split('/').none? do |segment|
|
|
198
|
+
decoded = URI::DEFAULT_PARSER.unescape(segment)
|
|
199
|
+
%w[. ..].include?(decoded) || decoded.match?(%r{[/\\\x00-\x20\x7f]}) || segment.match?(/%(?![0-9a-fA-F]{2})/)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require_relative 'client/version'
|
|
3
|
+
|
|
4
|
+
module HaveAPI
|
|
5
|
+
module Schema
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
def self.source_identity
|
|
9
|
+
root = __dir__
|
|
10
|
+
digest = Digest::SHA256.new
|
|
11
|
+
%w[haveapi/client/version.rb haveapi/schema.rb haveapi/schema/capture.rb
|
|
12
|
+
haveapi/schema/fetch_command.rb haveapi/schema/snapshot.rb].sort.each do |logical|
|
|
13
|
+
digest.update(logical).update("\0").update(File.binread(File.join(root, '..', logical))).update("\0")
|
|
14
|
+
end
|
|
15
|
+
{
|
|
16
|
+
'package_name' => 'haveapi-client'.freeze,
|
|
17
|
+
'version' => HaveAPI::Client::VERSION,
|
|
18
|
+
'source_sha256' => digest.hexdigest.freeze
|
|
19
|
+
}.freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require_relative 'schema/snapshot'
|
|
25
|
+
require_relative 'schema/capture'
|
|
26
|
+
require_relative 'schema/fetch_command'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require_relative 'schema_spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe HaveAPI::Schema::Capture do
|
|
4
|
+
include SchemaFixtures
|
|
5
|
+
|
|
6
|
+
before do
|
|
7
|
+
stub_const('RestClient::Request', Class.new)
|
|
8
|
+
allow(described_class).to receive(:require).with('rest-client').and_return(true)
|
|
9
|
+
allow(RestClient::Request).to receive(:execute).and_return(double(code: 200, to_s: JSON.generate(envelope)))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch(**options)
|
|
13
|
+
described_class.fetch(url: SchemaFixtures::URL, api_version: '7.0', profile: 'fixture', **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'requests only the exact mounted version URL with bounded TLS-verified OPTIONS' do
|
|
17
|
+
expect(fetch).to eq(snapshot)
|
|
18
|
+
expect(RestClient::Request).to have_received(:execute).once.with(
|
|
19
|
+
method: :options, url: SchemaFixtures::URL, headers: { accept: :json },
|
|
20
|
+
verify_ssl: true, max_redirects: 0, open_timeout: 10, read_timeout: 30
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'uses HTTP Basic credentials without discovery or query parameters' do
|
|
25
|
+
fetch(basic_user: 'fixture-user', basic_password: 'fixture-password')
|
|
26
|
+
expect(RestClient::Request).to have_received(:execute).once.with(hash_including(
|
|
27
|
+
url: SchemaFixtures::URL, user: 'fixture-user', password: 'fixture-password',
|
|
28
|
+
headers: { accept: :json }
|
|
29
|
+
))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'discovers token policy anonymously at the same URL and saves only the authorized surface' do
|
|
33
|
+
anonymous = envelope
|
|
34
|
+
anonymous['response']['authentication'] = { 'token' => { 'http_header' => 'X-Capture-Token' } }
|
|
35
|
+
authorized = Marshal.load(Marshal.dump(anonymous))
|
|
36
|
+
authorized['response']['resources'] = { 'authorized' => { 'resources' => {}, 'actions' => {} } }
|
|
37
|
+
allow(RestClient::Request).to receive(:execute).and_return(
|
|
38
|
+
double(code: 200, to_s: JSON.generate(anonymous)),
|
|
39
|
+
double(code: 200, to_s: JSON.generate(authorized))
|
|
40
|
+
)
|
|
41
|
+
provider = double(request_headers: { 'X-Capture-Token' => 'fixture-token' })
|
|
42
|
+
stub_const('HaveAPI::Client::Authentication::Token', Class.new)
|
|
43
|
+
allow(described_class).to receive(:require).with('haveapi/client').and_return(true)
|
|
44
|
+
allow(HaveAPI::Client::Authentication::Token).to receive(:new)
|
|
45
|
+
.with(nil, { http_header: 'X-Capture-Token' }, token: 'fixture-token').and_return(provider)
|
|
46
|
+
requests = []
|
|
47
|
+
allow(RestClient::Request).to receive(:execute).and_wrap_original do |_original, options|
|
|
48
|
+
requests << options
|
|
49
|
+
body = requests.length == 1 ? anonymous : authorized
|
|
50
|
+
double(code: 200, to_s: JSON.generate(body))
|
|
51
|
+
end
|
|
52
|
+
result = fetch(token: 'fixture-token')
|
|
53
|
+
expect(HaveAPI::Client::Authentication::Token).to have_received(:new)
|
|
54
|
+
.with(nil, { http_header: 'X-Capture-Token' }, token: 'fixture-token')
|
|
55
|
+
expect(requests.map { |r| r.fetch(:url) }).to eq([SchemaFixtures::URL, SchemaFixtures::URL])
|
|
56
|
+
expect(requests.map { |r| r.fetch(:headers) }).to eq([{ accept: :json }, { accept: :json, 'X-Capture-Token' => 'fixture-token' }])
|
|
57
|
+
expect(result['description']['resources'].keys).to eq(['authorized'])
|
|
58
|
+
expect(JSON.generate(result)).not_to include('fixture-token')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'refuses missing or invalid token discovery before sending the token' do
|
|
62
|
+
expect { fetch(token: 'fixture-token') }.to raise_error(HaveAPI::Schema::Error, /does not advertise/)
|
|
63
|
+
expect(RestClient::Request).to have_received(:execute).once
|
|
64
|
+
anonymous = envelope
|
|
65
|
+
anonymous['response']['authentication'] = { 'token' => { 'http_header' => "X-Bad\r\nfixture-secret" } }
|
|
66
|
+
allow(RestClient::Request).to receive(:execute).and_return(double(code: 200, to_s: JSON.generate(anonymous)))
|
|
67
|
+
stub_const('HaveAPI::Client::Authentication::Token', Class.new)
|
|
68
|
+
allow(described_class).to receive(:require).with('haveapi/client').and_return(true)
|
|
69
|
+
allow(HaveAPI::Client::Authentication::Token).to receive(:new).and_raise(ArgumentError, 'fixture-secret')
|
|
70
|
+
expect { fetch(token: 'fixture-token') }.to raise_error(HaveAPI::Schema::Error) do |error|
|
|
71
|
+
expect(error.message).to include('invalid HaveAPI token HTTP header')
|
|
72
|
+
expect(error.full_message).not_to include('fixture-secret', 'fixture-token')
|
|
73
|
+
end
|
|
74
|
+
expect(RestClient::Request).to have_received(:execute).twice
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'validates versions, endpoints, profile and exclusive credentials before dispatch' do
|
|
78
|
+
[
|
|
79
|
+
{ api_version: nil }, { url: 'https://schema.example.test/mount/' }, { profile: '' },
|
|
80
|
+
{ url: 'https://user:fixture-secret@schema.example.test/v7.0/' },
|
|
81
|
+
{ basic_user: 'user' }, { basic_password: 'secret' }, { basic_user: 'a:b', basic_password: 'secret' },
|
|
82
|
+
{ token: false }, { basic_user: false }, { basic_password: false },
|
|
83
|
+
{ token: '' }, { token: "secret\n" }, { token: 'secret', basic_user: 'user', basic_password: 'secret' }
|
|
84
|
+
].each do |options|
|
|
85
|
+
expect { fetch(**options) }.to raise_error(HaveAPI::Schema::Error)
|
|
86
|
+
end
|
|
87
|
+
expect(RestClient::Request).not_to have_received(:execute)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'rejects redirects, transport errors and unsuccessful or incoherent envelopes without leaking bodies' do
|
|
91
|
+
allow(RestClient::Request).to receive(:execute).and_raise(StandardError, 'fixture-password in raw HTTP error')
|
|
92
|
+
expect { fetch }.to raise_error(HaveAPI::Schema::Error) { |error| expect(error.full_message).not_to include('fixture-password') }
|
|
93
|
+
[double(code: 302, to_s: 'fixture-secret'), double(code: 200, to_s: 'fixture-secret'),
|
|
94
|
+
double(code: 200, to_s: JSON.generate(envelope.merge('status' => false))),
|
|
95
|
+
double(code: 200, to_s: JSON.generate(envelope.merge('version' => '3.0')))].each do |response|
|
|
96
|
+
allow(RestClient::Request).to receive(:execute).and_return(response)
|
|
97
|
+
expect { fetch }.to raise_error(HaveAPI::Schema::Error) { |error| expect(error.full_message).not_to include('fixture-secret') }
|
|
98
|
+
end
|
|
99
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Run after review with the prepared Ruby-client HTTP dependencies.
|
|
2
|
+
require_relative '../lib/haveapi/schema'
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'stringio'
|
|
7
|
+
require 'base64'
|
|
8
|
+
|
|
9
|
+
def assert_capture(condition, message)
|
|
10
|
+
raise message unless condition
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def serve_capture(socket, requests)
|
|
14
|
+
Timeout.timeout(2) do
|
|
15
|
+
method, path = socket.gets.to_s.split(' ', 3)
|
|
16
|
+
headers = {}
|
|
17
|
+
size = 0
|
|
18
|
+
while (line = socket.gets) && line != "\r\n"
|
|
19
|
+
size += line.bytesize
|
|
20
|
+
raise 'oversized fixture request' if size > 16_384
|
|
21
|
+
|
|
22
|
+
name, value = line.split(':', 2)
|
|
23
|
+
headers[name.downcase] = value.to_s.strip
|
|
24
|
+
end
|
|
25
|
+
requests << { method: method, path: path, headers: headers }
|
|
26
|
+
if path == '/redirect/v7.0/'
|
|
27
|
+
socket.write("HTTP/1.1 302 Found\r\nLocation: /redirect-target/v7.0/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
unless %w[/v7.0/ /mounted/v7.0/ /bad-header/v7.0/].include?(path) && method == 'OPTIONS'
|
|
31
|
+
socket.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
basic = "Basic #{Base64.strict_encode64('fixture-user:fixture-password')}"
|
|
36
|
+
authorized = headers['authorization'] == basic || headers['x-fixture-token'] == 'fixture-token'
|
|
37
|
+
resources = { 'public' => { 'actions' => {}, 'resources' => {} } }
|
|
38
|
+
resources['authorized'] = { 'actions' => {}, 'resources' => {} } if authorized
|
|
39
|
+
body = JSON.generate(
|
|
40
|
+
'status' => true, 'version' => '2.0', 'response' => {
|
|
41
|
+
'help' => path, 'meta' => { 'namespace' => '_meta' }, 'resources' => resources,
|
|
42
|
+
'authentication' => {
|
|
43
|
+
'token' => { 'http_header' => path.start_with?('/bad-header/') ? "X-Bad\r\nInjected" : 'X-Fixture-Token' }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
|
|
48
|
+
end
|
|
49
|
+
ensure
|
|
50
|
+
socket.close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
54
|
+
requests = []
|
|
55
|
+
worker = Thread.new do
|
|
56
|
+
loop { serve_capture(server.accept, requests) }
|
|
57
|
+
rescue IOError, Errno::EBADF
|
|
58
|
+
raise unless server.closed?
|
|
59
|
+
end
|
|
60
|
+
worker.report_on_exception = false
|
|
61
|
+
previous = %w[HAVEAPI_CAPTURE_WIRE_PASSWORD HAVEAPI_CAPTURE_WIRE_TOKEN].to_h { |name| [name, ENV.fetch(name, nil)] }
|
|
62
|
+
ENV['HAVEAPI_CAPTURE_WIRE_PASSWORD'] = 'fixture-password'
|
|
63
|
+
ENV['HAVEAPI_CAPTURE_WIRE_TOKEN'] = 'fixture-token'
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
Timeout.timeout(20) do
|
|
67
|
+
Dir.mktmpdir('haveapi-schema-wire-') do |dir|
|
|
68
|
+
base = "http://127.0.0.1:#{server.addr[1]}"
|
|
69
|
+
['', '/mounted'].each do |mount|
|
|
70
|
+
url = "#{base}#{mount}/v7.0/"
|
|
71
|
+
{
|
|
72
|
+
public: [],
|
|
73
|
+
basic: ['--basic-user', 'fixture-user', '--basic-password-env', 'HAVEAPI_CAPTURE_WIRE_PASSWORD'],
|
|
74
|
+
token: ['--token-env', 'HAVEAPI_CAPTURE_WIRE_TOKEN']
|
|
75
|
+
}.each do |mode, auth|
|
|
76
|
+
output = File.join(dir, 'schema.json')
|
|
77
|
+
File.write(output, 'previous snapshot')
|
|
78
|
+
out = StringIO.new
|
|
79
|
+
err = StringIO.new
|
|
80
|
+
first = requests.length
|
|
81
|
+
status = HaveAPI::Schema::FetchCommand.run(
|
|
82
|
+
[url, '--api-version', '7.0', '--profile', mode.to_s, '--output', output, *auth],
|
|
83
|
+
command_name: 'schema-wire', out: out, err: err
|
|
84
|
+
)
|
|
85
|
+
assert_capture(status == 0, "capture failed for #{mount}/#{mode}")
|
|
86
|
+
snapshot = HaveAPI::Schema::Snapshot.load(output)
|
|
87
|
+
assert_capture(snapshot['source_url'] == url && snapshot['mount_path'] == "#{mount}/", 'version or mount changed')
|
|
88
|
+
assert_capture(snapshot['description']['resources'].has_key?('authorized') == (mode != :public), 'wrong captured surface')
|
|
89
|
+
assert_capture(File.binread(output) == HaveAPI::Schema::Snapshot.canonical(snapshot).b, 'noncanonical saved bytes')
|
|
90
|
+
assert_capture(!File.read(output).include?('fixture-password') && !File.read(output).include?('fixture-token'), 'credential saved')
|
|
91
|
+
observed = requests.drop(first)
|
|
92
|
+
assert_capture(observed.length == (mode == :token ? 2 : 1), 'unexpected retry or auth action')
|
|
93
|
+
assert_capture(observed.all? { |r| r[:method] == 'OPTIONS' && r[:path] == "#{mount}/v7.0/" }, 'endpoint changed')
|
|
94
|
+
assert_capture(observed.all? { |r| r[:headers]['accept'].include?('json') }, 'JSON accept missing')
|
|
95
|
+
if mode == :token
|
|
96
|
+
assert_capture(!observed.first[:headers].has_key?('authorization') && !observed.first[:headers].has_key?('x-fixture-token'), 'discovery authenticated')
|
|
97
|
+
assert_capture(observed.last[:headers]['x-fixture-token'] == 'fixture-token', 'advertised header unused')
|
|
98
|
+
elsif mode == :basic
|
|
99
|
+
assert_capture(observed.first[:headers]['authorization'] == "Basic #{Base64.strict_encode64('fixture-user:fixture-password')}", 'Basic credentials missing')
|
|
100
|
+
end
|
|
101
|
+
assert_capture(out.string.empty? && err.string.empty?, 'unexpected command diagnostics')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
%w[redirect bad-header].each do |failure|
|
|
106
|
+
output = File.join(dir, 'schema.json')
|
|
107
|
+
File.write(output, 'preserve on failure')
|
|
108
|
+
err = StringIO.new
|
|
109
|
+
first = requests.length
|
|
110
|
+
status = HaveAPI::Schema::FetchCommand.run(
|
|
111
|
+
["#{base}/#{failure}/v7.0/", '--api-version', '7.0', '--profile', 'fixture',
|
|
112
|
+
'--output', output, '--token-env', 'HAVEAPI_CAPTURE_WIRE_TOKEN'],
|
|
113
|
+
command_name: 'schema-wire', out: StringIO.new, err: err
|
|
114
|
+
)
|
|
115
|
+
assert_capture(status == 1 && File.read(output) == 'preserve on failure', 'failed capture overwrote snapshot')
|
|
116
|
+
assert_capture(requests.length - first == 1, 'redirect followed or token sent after invalid discovery')
|
|
117
|
+
assert_capture(!err.string.include?('fixture-token') && !err.string.include?('Injected'), 'secret or header echoed')
|
|
118
|
+
end
|
|
119
|
+
assert_capture(Dir.children(dir) == ['schema.json'], 'temporary output leaked')
|
|
120
|
+
end
|
|
121
|
+
$stdout.puts 'Schema loopback: Public/Basic/existing-token root+mount capture, exact OPTIONS/header policy, redirects and atomic failure passed'
|
|
122
|
+
end
|
|
123
|
+
ensure
|
|
124
|
+
previous.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value }
|
|
125
|
+
server.close
|
|
126
|
+
assert_capture(worker.join(2), 'fixture worker did not stop')
|
|
127
|
+
worker.value
|
|
128
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require_relative 'schema_spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe HaveAPI::Schema::FetchCommand do
|
|
4
|
+
include SchemaFixtures
|
|
5
|
+
|
|
6
|
+
let(:out) { StringIO.new }
|
|
7
|
+
let(:err) { StringIO.new }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
allow(HaveAPI::Schema::Capture).to receive(:fetch).and_return(snapshot)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(output, *extra)
|
|
14
|
+
described_class.run([SchemaFixtures::URL, '--api-version', '7.0', '--profile', 'fixture',
|
|
15
|
+
'--output', output, *extra], out: out, err: err, command_name: 'haveapi-example-client')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'writes canonical bytes atomically and leaves no temporary files' do
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
path = File.join(dir, 'schema.json')
|
|
21
|
+
File.write(path, 'old')
|
|
22
|
+
expect(run(path)).to eq(0)
|
|
23
|
+
expect(File.binread(path)).to eq(HaveAPI::Schema::Snapshot.canonical(snapshot))
|
|
24
|
+
expect(Dir.children(dir)).to eq(['schema.json'])
|
|
25
|
+
expect(HaveAPI::Schema::Capture).to have_received(:fetch).with(
|
|
26
|
+
url: SchemaFixtures::URL, api_version: '7.0', profile: 'fixture',
|
|
27
|
+
basic_user: nil, basic_password: nil, token: nil, diagnostics: []
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'resolves Basic and existing-token credentials only through named environment variables' do
|
|
33
|
+
stub_const('ENV', ENV.to_h.merge('SCHEMA_TEST_PASSWORD' => 'fixture-password', 'SCHEMA_TEST_TOKEN' => 'fixture-token'))
|
|
34
|
+
Dir.mktmpdir do |dir|
|
|
35
|
+
path = File.join(dir, 'schema.json')
|
|
36
|
+
expect(run(path, '--basic-user', 'user', '--basic-password-env', 'SCHEMA_TEST_PASSWORD')).to eq(0)
|
|
37
|
+
expect(HaveAPI::Schema::Capture).to have_received(:fetch).with(hash_including(basic_user: 'user', basic_password: 'fixture-password'))
|
|
38
|
+
expect(run(path, '--token-env', 'SCHEMA_TEST_TOKEN')).to eq(0)
|
|
39
|
+
expect(HaveAPI::Schema::Capture).to have_received(:fetch).with(hash_including(token: 'fixture-token'))
|
|
40
|
+
expect(out.string + err.string + File.read(path)).not_to include('fixture-password', 'fixture-token')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'rejects invalid credential options before network or output mutation without echoing raw arguments' do
|
|
45
|
+
stub_const('ENV', ENV.to_h.merge('SCHEMA_EMPTY' => '', 'SCHEMA_BAD' => "fixture-secret\n"))
|
|
46
|
+
Dir.mktmpdir do |dir|
|
|
47
|
+
path = File.join(dir, 'schema.json')
|
|
48
|
+
File.write(path, 'old')
|
|
49
|
+
[
|
|
50
|
+
['--basic-user', 'user'], ['--basic-password-env', 'MISSING'], ['--token-env', 'MISSING'],
|
|
51
|
+
['--token-env', 'SCHEMA_EMPTY'], ['--token-env', 'SCHEMA_BAD'], ['--token-env', 'bad=name'],
|
|
52
|
+
['--token-env', 'SCHEMA_EMPTY', '--basic-user', 'user', '--basic-password-env', 'SCHEMA_BAD'],
|
|
53
|
+
['--basic-password', 'fixture-secret'], ['--unknown=fixture-secret']
|
|
54
|
+
].each do |args|
|
|
55
|
+
expect(run(path, *args)).to eq(1)
|
|
56
|
+
expect(File.read(path)).to eq('old')
|
|
57
|
+
expect(Dir.children(dir)).to eq(['schema.json'])
|
|
58
|
+
end
|
|
59
|
+
expect(HaveAPI::Schema::Capture).not_to have_received(:fetch)
|
|
60
|
+
expect(err.string).not_to include('fixture-secret')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'preserves old output on capture, validation and atomic-save failures' do
|
|
65
|
+
Dir.mktmpdir do |dir|
|
|
66
|
+
path = File.join(dir, 'schema.json')
|
|
67
|
+
File.write(path, 'old')
|
|
68
|
+
allow(HaveAPI::Schema::Capture).to receive(:fetch).and_raise(HaveAPI::Schema::Error, 'capture failed')
|
|
69
|
+
expect(run(path)).to eq(1)
|
|
70
|
+
expect(File.read(path)).to eq('old')
|
|
71
|
+
allow(HaveAPI::Schema::Capture).to receive(:fetch).and_return(snapshot.merge('protocol_version' => '3.0'))
|
|
72
|
+
expect(run(path)).to eq(1)
|
|
73
|
+
expect(File.read(path)).to eq('old')
|
|
74
|
+
allow(HaveAPI::Schema::Capture).to receive(:fetch).and_return(snapshot)
|
|
75
|
+
allow(File).to receive(:rename).and_raise(Errno::EACCES, 'fixture-secret')
|
|
76
|
+
expect(run(path)).to eq(1)
|
|
77
|
+
expect(File.read(path)).to eq('old')
|
|
78
|
+
expect(Dir.children(dir)).to eq(['schema.json'])
|
|
79
|
+
expect(err.string).not_to include('fixture-secret')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'refuses a symlink destination or missing parent before acquisition' do
|
|
84
|
+
Dir.mktmpdir do |dir|
|
|
85
|
+
target = File.join(dir, 'target')
|
|
86
|
+
File.write(target, 'keep')
|
|
87
|
+
path = File.join(dir, 'schema.json')
|
|
88
|
+
File.symlink(target, path)
|
|
89
|
+
expect(run(path)).to eq(1)
|
|
90
|
+
expect(File.read(target)).to eq('keep')
|
|
91
|
+
expect(run(File.join(dir, 'missing/schema.json'))).to eq(1)
|
|
92
|
+
expect(Dir.children(dir).sort).to eq(%w[schema.json target])
|
|
93
|
+
expect(HaveAPI::Schema::Capture).not_to have_received(:fetch)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'returns help without loading capture dependencies or creating output' do
|
|
98
|
+
expect(described_class.run(['--help'], out: out, err: err, command_name: 'haveapi-example-client')).to eq(0)
|
|
99
|
+
expect(out.string).to include('fetch URL', '--api-version', '--basic-password-env', '--token-env')
|
|
100
|
+
expect(HaveAPI::Schema::Capture).not_to have_received(:fetch)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require_relative 'schema_spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe HaveAPI::Schema::Snapshot do
|
|
4
|
+
include SchemaFixtures
|
|
5
|
+
|
|
6
|
+
it 'loads offline without the dynamic client, transport or a bundle' do
|
|
7
|
+
script = <<~RUBY
|
|
8
|
+
require 'haveapi/schema'
|
|
9
|
+
abort 'dynamic client loaded' if defined?(RestClient) || defined?(HaveAPI::Client::Client)
|
|
10
|
+
identity = HaveAPI::Schema.source_identity
|
|
11
|
+
abort 'mutable identity' unless identity.frozen? && identity.values.all?(&:frozen?)
|
|
12
|
+
puts identity.fetch('package_name')
|
|
13
|
+
puts identity.fetch('version')
|
|
14
|
+
RUBY
|
|
15
|
+
lib = File.expand_path('../lib', __dir__)
|
|
16
|
+
out, err, status = Open3.capture3({ 'RUBYOPT' => nil, 'RUBYLIB' => nil }, RbConfig.ruby, '--disable-gems', '-I', lib, '-e', script)
|
|
17
|
+
expect(status.success?).to be(true), err
|
|
18
|
+
expect(out.lines.map(&:strip)).to eq(['haveapi-client', HaveAPI::Client::VERSION])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'uses the established canonical bytes and preserves array order and UTF-8 data' do
|
|
22
|
+
expected = <<~JSON
|
|
23
|
+
{
|
|
24
|
+
"a": "č",
|
|
25
|
+
"z": [
|
|
26
|
+
{
|
|
27
|
+
"a": 2,
|
|
28
|
+
"b": 1
|
|
29
|
+
},
|
|
30
|
+
0
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
JSON
|
|
34
|
+
expect(described_class.canonical('z' => [{ 'b' => 1, 'a' => 2 }, 0], 'a' => 'č')).to eq(expected)
|
|
35
|
+
expect(described_class.sha256(snapshot)).to eq(Digest::SHA256.hexdigest(described_class.canonical(snapshot)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'matches a mounted format 1 snapshot without rewriting saved data' do
|
|
39
|
+
expected = {
|
|
40
|
+
'schema_format' => 1, 'api_version' => '7.0', 'protocol_version' => '2.0',
|
|
41
|
+
'source_url' => SchemaFixtures::URL, 'profile' => 'fixture', 'mount_path' => '/mount/', 'description' => description
|
|
42
|
+
}
|
|
43
|
+
expect(snapshot).to eq(expected)
|
|
44
|
+
Dir.mktmpdir do |dir|
|
|
45
|
+
path = File.join(dir, 'schema.json')
|
|
46
|
+
bytes = described_class.canonical(expected)
|
|
47
|
+
File.write(path, bytes)
|
|
48
|
+
expect(described_class.load(path)).to eq(expected)
|
|
49
|
+
expect(File.binread(path)).to eq(bytes)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'preserves Custom/default and choice keys while stripping prose and protected defaults' do
|
|
54
|
+
diagnostics = []
|
|
55
|
+
data = { 'help' => '/data?token=data', 'fetch_url' => 'custom:value', 'description' => 'data' }
|
|
56
|
+
source = { 'parameters' => {
|
|
57
|
+
'description' => { 'type' => 'Custom', 'default' => data, 'description' => 'prose' },
|
|
58
|
+
'secret' => { 'protected' => true, 'default' => 'fixture-secret' },
|
|
59
|
+
'choice' => { 'validators' => { 'include' => { 'values' => { 'help' => 'label' }, 'message' => 'private' } } }
|
|
60
|
+
} }
|
|
61
|
+
cleaned = described_class.sanitize(source, diagnostics: diagnostics)
|
|
62
|
+
expect(cleaned.dig('parameters', 'description')).to eq('type' => 'Custom', 'default' => data)
|
|
63
|
+
expect(cleaned.dig('parameters', 'secret')).not_to have_key('default')
|
|
64
|
+
expect(cleaned.dig('parameters', 'choice', 'validators', 'include')).to eq('values' => { 'help' => nil })
|
|
65
|
+
expect(diagnostics).to eq(['removed protected default: parameters/secret'])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'rejects nested structural endpoints during capture and load without echoing their values' do
|
|
69
|
+
%w[help path choices_path choices_url].each do |key|
|
|
70
|
+
value = key.end_with?('_url') ? 'https://user:fixture-secret@example.test/' : '/mount/v7.0/?token=fixture-secret'
|
|
71
|
+
schema = snapshot
|
|
72
|
+
schema['description']['resources']['widget']['choices'] = { key => value }
|
|
73
|
+
[-> { described_class.validate(schema) },
|
|
74
|
+
lambda {
|
|
75
|
+
described_class.from_envelope(envelope.merge('response' => schema['description']), api_version: '7.0',
|
|
76
|
+
source_url: SchemaFixtures::URL, profile: 'fixture', mount_path: '/mount/')
|
|
77
|
+
}].each do |operation|
|
|
78
|
+
expect(&operation).to raise_error(HaveAPI::Schema::Error) do |error|
|
|
79
|
+
expect(error.message).to include("resources/widget/choices/#{key}")
|
|
80
|
+
expect(error.message).not_to include('fixture-secret', value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'rejects malformed envelopes, unsafe paths, mismatched versions and protocols' do
|
|
87
|
+
[nil, {}, { 'status' => false }, { 'status' => true }].each do |value|
|
|
88
|
+
expect { described_class.from_envelope(value, api_version: '7.0', source_url: SchemaFixtures::URL, profile: 'fixture') }
|
|
89
|
+
.to raise_error(HaveAPI::Schema::Error)
|
|
90
|
+
end
|
|
91
|
+
%w[/../ /%2e%2e/ /%2f/ /bad%/ //evil/].each { |path| expect(described_class.safe_path?(path)).to be(false) }
|
|
92
|
+
expect { described_class.validate(snapshot.merge('protocol_version' => '3.0')) }.to raise_error(HaveAPI::Schema::Error)
|
|
93
|
+
expect { described_class.validate(snapshot.merge('api_version' => '8.0')) }.to raise_error(HaveAPI::Schema::Error)
|
|
94
|
+
expect { described_class.endpoint!('https://user:secret@example.test/v7.0/') }.to raise_error(HaveAPI::Schema::Error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'binds source identity to the actual version and all five shared logical source files' do
|
|
98
|
+
lib = File.expand_path('../lib', __dir__)
|
|
99
|
+
paths = %w[haveapi/schema.rb haveapi/schema/snapshot.rb haveapi/schema/capture.rb
|
|
100
|
+
haveapi/schema/fetch_command.rb haveapi/client/version.rb].sort
|
|
101
|
+
digest = Digest::SHA256.new
|
|
102
|
+
paths.each { |path| digest.update(path).update("\0").update(File.binread(File.join(lib, path))).update("\0") }
|
|
103
|
+
expect(HaveAPI::Schema.source_identity).to eq(
|
|
104
|
+
'package_name' => 'haveapi-client', 'version' => HaveAPI::Client::VERSION, 'source_sha256' => digest.hexdigest
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'keeps relative owned-file segments compatible without relaxing absolute endpoint paths' do
|
|
109
|
+
expect(described_class.safe_segments?('resources/widget/show.rs')).to be(true)
|
|
110
|
+
expect(described_class.safe_segments?('/mount/v7.0/')).to be(true)
|
|
111
|
+
['../outside.rs', 'resources/./show.rs', 'resources/%2e%2e/show.rs', 'resources/%2f/show.rs',
|
|
112
|
+
'resources/%5c/show.rs', 'resources/bad%/show.rs', 'resources/with space.rs', "resources/\nshow.rs",
|
|
113
|
+
'resources/show.rs?token=data', 'resources/show.rs#fragment', '//outside', nil].each do |path|
|
|
114
|
+
expect(described_class.safe_segments?(path)).to be(false)
|
|
115
|
+
end
|
|
116
|
+
expect(described_class.safe_path?('resources/widget/show.rs')).to be(false)
|
|
117
|
+
expect { described_class.endpoint!('https://schema.example.test/mount/%2e%2e/v7.0/') }
|
|
118
|
+
.to raise_error(HaveAPI::Schema::Error)
|
|
119
|
+
expect { described_class.validate_machine_endpoint('path', 'resources/widget/show.rs') }
|
|
120
|
+
.to raise_error(HaveAPI::Schema::Error)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require_relative '../lib/haveapi/schema'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'rbconfig'
|
|
6
|
+
|
|
7
|
+
module SchemaFixtures
|
|
8
|
+
URL = 'https://schema.example.test/mount/v7.0/'.freeze
|
|
9
|
+
|
|
10
|
+
def description
|
|
11
|
+
{
|
|
12
|
+
'help' => '/mount/v7.0/', 'meta' => { 'namespace' => '_meta' },
|
|
13
|
+
'authentication' => {}, 'resources' => {
|
|
14
|
+
'widget' => { 'actions' => {}, 'resources' => {} }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def envelope
|
|
20
|
+
{ 'status' => true, 'version' => '2.0', 'response' => description }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def snapshot
|
|
24
|
+
HaveAPI::Schema::Snapshot.from_envelope(envelope, api_version: '7.0', source_url: URL,
|
|
25
|
+
profile: 'fixture', mount_path: '/mount/')
|
|
26
|
+
end
|
|
27
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -152,6 +152,10 @@ files:
|
|
|
152
152
|
- lib/haveapi/client/validators/numericality.rb
|
|
153
153
|
- lib/haveapi/client/validators/presence.rb
|
|
154
154
|
- lib/haveapi/client/version.rb
|
|
155
|
+
- lib/haveapi/schema.rb
|
|
156
|
+
- lib/haveapi/schema/capture.rb
|
|
157
|
+
- lib/haveapi/schema/fetch_command.rb
|
|
158
|
+
- lib/haveapi/schema/snapshot.rb
|
|
155
159
|
- lib/restclient_ext/resource.rb
|
|
156
160
|
- spec/action_security_spec.rb
|
|
157
161
|
- spec/authentication_token_security_spec.rb
|
|
@@ -163,6 +167,11 @@ files:
|
|
|
163
167
|
- spec/integration/typed_input_spec.rb
|
|
164
168
|
- spec/load_spec.rb
|
|
165
169
|
- spec/resource_instance_association_spec.rb
|
|
170
|
+
- spec/schema_capture_spec.rb
|
|
171
|
+
- spec/schema_capture_wire.rb
|
|
172
|
+
- spec/schema_fetch_command_spec.rb
|
|
173
|
+
- spec/schema_snapshot_spec.rb
|
|
174
|
+
- spec/schema_spec_helper.rb
|
|
166
175
|
- spec/spec_helper.rb
|
|
167
176
|
- spec/support/test_server.rb
|
|
168
177
|
- spec/validators/inclusion_spec.rb
|