haveapi-client 0.30.2 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c1db16e3a0212003b9eb0295ce126c0d7eabb2a467b970680b73dcad43540d8
4
- data.tar.gz: b56c37f31b2e8fe7c0455d2a77d99b9644ca8e7328016227c81b9f3d12daa549
3
+ metadata.gz: 46e2054f75727aa75772d222ef1592756fba8c23d2243fc98be22b78252e79c6
4
+ data.tar.gz: 918d3ed21c25f93f7d7b3d991862dd60b2518738f2ab89a3670c42f62e5538ac
5
5
  SHA512:
6
- metadata.gz: 77374baf3f68a17d93e2114f890566bb2842521de475937e6ab031f56ade293ccb6d83ff785b9742966301a1a3748bf6c96768a6c52e7e5ee16c85a0dc3c9534
7
- data.tar.gz: fcdeab4afab35236ecb439caf9a52f2ecfe132226ef55bfdaecacc7bd684552209a2ff670cf8ebb6278bd755ada599a5407a2bd5631c06fb8214cd9326709d9f
6
+ metadata.gz: c52e5738cd4a0ecba132d5c2fa90d157357a60fb341691b3b7a8c1cfa832a09cc1293ababc0c3ec39eecf80b72a4c92139456f4a69a658ef44a2c32e24fc8acc
7
+ data.tar.gz: 474ab1e28f2d32373497c68608f5c9c4aa01b20292c0723251328645f8172550c6744e6311369ab59160ac0902506e40eaee1067374127f6f98299c42616ed86
@@ -1,6 +1,6 @@
1
1
  module HaveAPI
2
2
  module Client
3
3
  PROTOCOL_VERSION = '2.0'.freeze
4
- VERSION = '0.30.2'.freeze
4
+ VERSION = '0.31.1'.freeze
5
5
  end
6
6
  end
@@ -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.30.2
4
+ version: 0.31.1
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