pandexio 0.0.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/lib/module.rb +151 -0
- data/lib/request.rb +25 -0
- data/lib/signing_algorithms.rb +37 -0
- data/lib/signing_attributes.rb +22 -0
- data/lib/signing_mechanisms.rb +17 -0
- data/lib/signing_options.rb +30 -0
- data/test/test_header_signing.rb +35 -0
- data/test/test_query_string_signing.rb +44 -0
- metadata +51 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d09ed0b91e468d7feba7093a009224a310e77766
|
4
|
+
data.tar.gz: ac4d71c496c6769bc4b95a0e7514597ef3c0df7a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 48d13f7f4055315ad4ff67efd8d6ceecbe83355f1acf27ba695af5618389902c67ba606d6018d9bcbcc0fed9847c1352aa62b687c431916fb31b823cde364174
|
7
|
+
data.tar.gz: 44d1a250babf03e9083bcac1f2f53b73477879563d056b0ef2c9d062aea05b5707d4f598c9c89ac069b85c10404ceb7eaa608d2246a829ef831d7585b7a27592
|
data/lib/module.rb
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'stringio'
|
3
|
+
require 'digest/hmac'
|
4
|
+
require_relative 'request.rb'
|
5
|
+
require_relative 'signing_algorithms.rb'
|
6
|
+
require_relative 'signing_attributes.rb'
|
7
|
+
require_relative 'signing_mechanisms.rb'
|
8
|
+
require_relative 'signing_options.rb'
|
9
|
+
|
10
|
+
module Pandexio
|
11
|
+
|
12
|
+
LINE_BREAK = "\r\n"
|
13
|
+
private_constant :LINE_BREAK
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def self.ordinal_key_value_sort(a, b)
|
18
|
+
|
19
|
+
a_codepoints, b_codepoints = a[0].codepoints, b[0].codepoints
|
20
|
+
|
21
|
+
max_i = [a_codepoints.size, b_codepoints.size].min
|
22
|
+
|
23
|
+
for i in 0..max_i
|
24
|
+
a_codepoint = a_codepoints[i]
|
25
|
+
b_codepoint = b_codepoints[i]
|
26
|
+
return -1 if a_codepoint < b_codepoint
|
27
|
+
return 1 if a_codepoint > b_codepoint
|
28
|
+
end
|
29
|
+
|
30
|
+
return 0
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.build_canonical_query_string(query_parameters)
|
35
|
+
|
36
|
+
temp_query_parameters = query_parameters.dup
|
37
|
+
|
38
|
+
query_sort = ->(a,b) { ordinal_key_value_sort(a, b) }
|
39
|
+
temp_query_parameters = temp_query_parameters.sort(&query_sort)
|
40
|
+
|
41
|
+
canonical_query_string = StringIO.new
|
42
|
+
|
43
|
+
temp_query_parameters.each do |key, value|
|
44
|
+
next if key == SigningAttributes::ALGORITHM || key == SigningAttributes::CREDENTIAL || key == SigningAttributes::SIGNED_HEADERS || key == SigningAttributes::SIGNATURE
|
45
|
+
canonical_query_string << "&" if canonical_query_string.length > 0
|
46
|
+
canonical_query_string << "#{key}=#{value}"
|
47
|
+
end
|
48
|
+
|
49
|
+
return canonical_query_string.string
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.build_canonical_headers(headers)
|
54
|
+
|
55
|
+
temp_headers = {}
|
56
|
+
|
57
|
+
headers.each do |key, value|
|
58
|
+
next if key == SigningAttributes::AUTHORIZATION
|
59
|
+
temp_headers[key.downcase.strip] = value
|
60
|
+
end
|
61
|
+
|
62
|
+
header_sort = ->(a,b) { ordinal_key_value_sort(a, b) }
|
63
|
+
temp_headers = temp_headers.sort(&header_sort)
|
64
|
+
|
65
|
+
canonical_headers, signed_headers = StringIO.new, StringIO.new
|
66
|
+
|
67
|
+
temp_headers.each do |key, value|
|
68
|
+
next if key == SigningAttributes::AUTHORIZATION
|
69
|
+
canonical_headers << LINE_BREAK if canonical_headers.length > 0
|
70
|
+
canonical_headers << "#{key}:#{value}"
|
71
|
+
signed_headers << ";" if signed_headers.length > 0
|
72
|
+
signed_headers << "#{key}"
|
73
|
+
end
|
74
|
+
|
75
|
+
return canonical_headers.string, signed_headers.string
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.build_canonical_payload(payload, digest)
|
80
|
+
return digest.hexdigest(payload)
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.build_canonical_request(request, digest)
|
84
|
+
canonical_query_string = build_canonical_query_string(request.query_parameters)
|
85
|
+
canonical_headers, signed_headers = build_canonical_headers(request.headers)
|
86
|
+
canonical_payload = build_canonical_payload(request.payload, digest)
|
87
|
+
canonical_request = "#{request.method}#{LINE_BREAK}#{request.path}#{LINE_BREAK}#{canonical_query_string}#{LINE_BREAK}#{canonical_headers}#{LINE_BREAK}#{signed_headers}#{LINE_BREAK}#{canonical_payload}"
|
88
|
+
return canonical_request, signed_headers
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.build_string_to_sign(canonical_request, signing_options)
|
92
|
+
return "#{signing_options.algorithm}#{LINE_BREAK}#{signing_options.date.iso8601}#{LINE_BREAK}#{canonical_request}"
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.generate_signature(string_to_sign, signing_options, digest)
|
96
|
+
return Digest::HMAC.hexdigest(string_to_sign, signing_options.domain_key, digest)
|
97
|
+
end
|
98
|
+
|
99
|
+
public
|
100
|
+
|
101
|
+
def self.to_authorized_request(normalized_request, signing_options)
|
102
|
+
|
103
|
+
raise ArgumentError, 'normalized_request must be of type Pandexio::Request and cannot be nil' unless !normalized_request.nil? && normalized_request.is_a?(Request)
|
104
|
+
raise ArgumentError, 'normalized_request.query_parameters must be of type Hash and cannot be nil' unless !normalized_request.query_parameters.nil? && normalized_request.query_parameters.is_a?(Hash)
|
105
|
+
raise ArgumentError, 'normalized_request.headers must be of type Hash and cannot be nil' unless !normalized_request.headers.nil? && normalized_request.headers.is_a?(Hash)
|
106
|
+
|
107
|
+
raise ArgumentError, 'signing_options must be of type Pandexio::SigningOptions cannot be nil' unless !signing_options.nil? && signing_options.is_a?(SigningOptions)
|
108
|
+
raise ArgumentError, 'signing_options.domain_id must be of type String and cannot be nil or empty' unless !signing_options.domain_id.nil? && signing_options.domain_id.is_a?(String) && !signing_options.domain_id.empty?
|
109
|
+
raise ArgumentError, 'signing_options.domain_key must be of type String and cannot be nil or empty' unless !signing_options.domain_key.nil? && signing_options.domain_key.is_a?(String) && !signing_options.domain_key.empty?
|
110
|
+
raise ArgumentError, 'signing_options.algorithm must be of type String and cannot be nil or empty' unless SigningAlgorithms.is_v(signing_options.algorithm)
|
111
|
+
raise ArgumentError, 'signing_options.mechanism must be a valid signing mechanism' unless SigningMechanisms.is_v(signing_options.mechanism)
|
112
|
+
raise ArgumentError, 'signing_options.date must be of type Time and cannot be nil' unless !signing_options.date.nil? && signing_options.date.is_a?(Time)
|
113
|
+
raise ArgumentError, 'signing_options.expires must be of type Fixnum and cannot be nil or empty' unless !signing_options.expires.nil? && signing_options.expires.is_a?(Fixnum) && signing_options.expires > 0
|
114
|
+
raise ArgumentError, 'signing_options.originator must be of type String and cannot be nil or empty' unless !signing_options.originator.nil? && signing_options.originator.is_a?(String) && !signing_options.originator.empty?
|
115
|
+
raise ArgumentError, 'signing_options.email_address must be of type String and cannot be nil or empty' unless !signing_options.email_address.nil? && signing_options.email_address.is_a?(String) && !signing_options.email_address.empty?
|
116
|
+
raise ArgumentError, 'signing_options.display_name must be of type String and cannot be nil or empty' unless !signing_options.display_name.nil? && signing_options.display_name.is_a?(String) && !signing_options.display_name.empty?
|
117
|
+
|
118
|
+
authorized_request = normalized_request.dup
|
119
|
+
|
120
|
+
append = -> (p) do
|
121
|
+
p[SigningAttributes::DATE] = signing_options.date.iso8601
|
122
|
+
p[SigningAttributes::EXPIRES] = signing_options.expires
|
123
|
+
p[SigningAttributes::ORIGINATOR] = signing_options.originator
|
124
|
+
p[SigningAttributes::EMAIL_ADDRESS] = signing_options.email_address
|
125
|
+
p[SigningAttributes::DISPLAY_NAME] = signing_options.display_name
|
126
|
+
p[SigningAttributes::THUMBNAIL] = signing_options.thumbnail if !signing_options.thumbnail.nil? && signing_options.thumbnail.is_a?(String) && !signing_options.thumbnail.empty?
|
127
|
+
end
|
128
|
+
|
129
|
+
append.call(
|
130
|
+
signing_options.mechanism == SigningMechanisms::QUERY_STRING ? authorized_request.query_parameters :
|
131
|
+
signing_options.mechanism == SigningMechanisms::HEADER ? authorized_request.headers : {})
|
132
|
+
|
133
|
+
digest = SigningAlgorithms.to_d(signing_options.algorithm)
|
134
|
+
canonical_request, signed_headers = build_canonical_request(authorized_request, digest)
|
135
|
+
string_to_sign = build_string_to_sign(canonical_request, signing_options)
|
136
|
+
signature = generate_signature(string_to_sign, signing_options, digest)
|
137
|
+
|
138
|
+
if signing_options.mechanism == SigningMechanisms::QUERY_STRING
|
139
|
+
authorized_request.query_parameters[SigningAttributes::ALGORITHM] = signing_options.algorithm
|
140
|
+
authorized_request.query_parameters[SigningAttributes::CREDENTIAL] = signing_options.domain_id
|
141
|
+
authorized_request.query_parameters[SigningAttributes::SIGNED_HEADERS] = signed_headers
|
142
|
+
authorized_request.query_parameters[SigningAttributes::SIGNATURE] = signature
|
143
|
+
elsif signing_options.mechanism == SigningMechanisms::HEADER
|
144
|
+
authorized_request.headers[SigningAttributes::AUTHORIZATION] = "#{signing_options.algorithm} Credential=#{signing_options.domain_id}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
|
145
|
+
end
|
146
|
+
|
147
|
+
return authorized_request
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
data/lib/request.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
module Pandexio
|
2
|
+
|
3
|
+
class Request
|
4
|
+
|
5
|
+
def initialize(params = {})
|
6
|
+
@method = params.fetch(:method, nil)
|
7
|
+
@path = params.fetch(:path, nil)
|
8
|
+
@query_parameters = params.fetch(:query_parameters, {})
|
9
|
+
@headers = params.fetch(:headers, {})
|
10
|
+
@payload = params.fetch(:payload, nil)
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :method
|
14
|
+
attr_accessor :path
|
15
|
+
attr_accessor :query_parameters
|
16
|
+
attr_accessor :headers
|
17
|
+
attr_accessor :payload
|
18
|
+
|
19
|
+
def to_s
|
20
|
+
"#{@method} #{@path}#{LINE_BREAK}query_parameters: #{query_parameters}#{LINE_BREAK}headers: #{headers}#{LINE_BREAK}payload: #{payload}"
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Pandexio
|
2
|
+
|
3
|
+
class SigningAlgorithms
|
4
|
+
|
5
|
+
PDX_HMAC_MD5 = "PDX-HMAC-MD5"
|
6
|
+
PDX_HMAC_SHA1 = "PDX-HMAC-SHA1"
|
7
|
+
PDX_HMAC_SHA256 = "PDX-HMAC-SHA256"
|
8
|
+
PDX_HMAC_SHA384 = "PDX-HMAC-SHA384"
|
9
|
+
PDX_HMAC_SHA512 = "PDX-HMAC-SHA512"
|
10
|
+
|
11
|
+
def self.is_v(a)
|
12
|
+
|
13
|
+
return !a.nil? && a.is_a?(String) &&
|
14
|
+
(a == PDX_HMAC_MD5 ||
|
15
|
+
a == PDX_HMAC_SHA1 ||
|
16
|
+
a == PDX_HMAC_SHA256 ||
|
17
|
+
a == PDX_HMAC_SHA384 ||
|
18
|
+
a == PDX_HMAC_SHA512)
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.to_d(a)
|
23
|
+
|
24
|
+
return case a
|
25
|
+
when SigningAlgorithms::PDX_HMAC_MD5; Digest::MD5
|
26
|
+
when SigningAlgorithms::PDX_HMAC_SHA1; Digest::SHA1
|
27
|
+
when SigningAlgorithms::PDX_HMAC_SHA256; Digest::SHA256
|
28
|
+
when SigningAlgorithms::PDX_HMAC_SHA384; Digest::SHA384
|
29
|
+
when SigningAlgorithms::PDX_HMAC_SHA512; Digest::SHA512
|
30
|
+
else raise 'Invalid signing algorithm'
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Pandexio
|
2
|
+
|
3
|
+
class SigningAttributes
|
4
|
+
# Used by "headers" only
|
5
|
+
AUTHORIZATION = "Authorization"
|
6
|
+
|
7
|
+
# Used by "query_parameters" only
|
8
|
+
ALGORITHM = "X-Pdx-Algorithm"
|
9
|
+
CREDENTIAL = "X-Pdx-Credential"
|
10
|
+
SIGNED_HEADERS = "X-Pdx-SignedHeaders"
|
11
|
+
SIGNATURE = "X-Pdx-Signature"
|
12
|
+
|
13
|
+
# Used by "headers" and "query_parameters"
|
14
|
+
DATE = "X-Pdx-Date"
|
15
|
+
EXPIRES = "X-Pdx-Expires"
|
16
|
+
ORIGINATOR = "X-Pdx-Originator"
|
17
|
+
EMAIL_ADDRESS = "X-Pdx-EmailAddress"
|
18
|
+
DISPLAY_NAME = "X-Pdx-DisplayName"
|
19
|
+
THUMBNAIL = "X-Pdx-Thumbnail"
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Pandexio
|
2
|
+
|
3
|
+
class SigningOptions
|
4
|
+
def initialize(params = {})
|
5
|
+
@algorithm = params.fetch(:algorithm, nil)
|
6
|
+
@mechanism = params.fetch(:mechanism, nil)
|
7
|
+
@domain_id = params.fetch(:domain_id, nil)
|
8
|
+
@domain_key = params.fetch(:domain_key, nil)
|
9
|
+
@date = params.fetch(:date, nil)
|
10
|
+
@expires = params.fetch(:expires, nil)
|
11
|
+
@originator = params.fetch(:originator, nil)
|
12
|
+
@email_address = params.fetch(:email_address, nil)
|
13
|
+
@display_name = params.fetch(:display_name, nil)
|
14
|
+
@thumbnail = params.fetch(:thumbnail, nil)
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_accessor :algorithm
|
18
|
+
attr_accessor :mechanism
|
19
|
+
attr_accessor :domain_id
|
20
|
+
attr_accessor :domain_key
|
21
|
+
attr_accessor :date
|
22
|
+
attr_accessor :expires
|
23
|
+
attr_accessor :originator
|
24
|
+
attr_accessor :email_address
|
25
|
+
attr_accessor :display_name
|
26
|
+
attr_accessor :thumbnail
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require_relative '../lib/module.rb'
|
3
|
+
|
4
|
+
describe Pandexio do
|
5
|
+
before do
|
6
|
+
normalized_request = Pandexio::Request.new(
|
7
|
+
:method => "PUT",
|
8
|
+
:path => "/asdf/qwer/1234/title",
|
9
|
+
:query_parameters => { "nonce" => "987654321", "Baseline" => "5" },
|
10
|
+
:headers => { "sample" => "example", "Host" => "localhost" },
|
11
|
+
:payload => "testing")
|
12
|
+
|
13
|
+
date = Time.utc(2014, 11, 21, 13, 43, 15)
|
14
|
+
|
15
|
+
signing_options = Pandexio::SigningOptions.new(
|
16
|
+
:algorithm => Pandexio::SigningAlgorithms::PDX_HMAC_SHA256,
|
17
|
+
:mechanism => Pandexio::SigningMechanisms::HEADER,
|
18
|
+
:domain_id => "1234567890",
|
19
|
+
:domain_key => "asdfjklqwerzxcv",
|
20
|
+
:date => date,
|
21
|
+
:expires => 90,
|
22
|
+
:originator => "HeaderSigningTest",
|
23
|
+
:email_address => "Anonymous",
|
24
|
+
:display_name => "Anonymous")
|
25
|
+
|
26
|
+
@authorized_request = Pandexio::to_authorized_request(normalized_request, signing_options)
|
27
|
+
@authorized_request = Pandexio::to_authorized_request(normalized_request, signing_options)
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "#header_signing" do
|
31
|
+
it "returns the correct authorization header" do
|
32
|
+
@authorized_request.headers["Authorization"].must_equal "PDX-HMAC-SHA256 Credential=1234567890, SignedHeaders=host;sample;x-pdx-date;x-pdx-displayname;x-pdx-emailaddress;x-pdx-expires;x-pdx-originator, Signature=a2e3dbc31b712bec6071dc7c5770bc60d4b03afa20e8329e6f4f6a2d74d32709"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require_relative '../lib/module.rb'
|
3
|
+
|
4
|
+
describe Pandexio do
|
5
|
+
before do
|
6
|
+
normalized_request = Pandexio::Request.new(
|
7
|
+
:method => "PUT",
|
8
|
+
:path => "/asdf/qwer/1234/title",
|
9
|
+
:query_parameters => { "nonce" => "987654321", "Baseline" => "5" },
|
10
|
+
:headers => { "sample" => "example", "Host" => "localhost" },
|
11
|
+
:payload => "testing")
|
12
|
+
|
13
|
+
date = Time.utc(2014, 11, 21, 13, 43, 15)
|
14
|
+
|
15
|
+
signing_options = Pandexio::SigningOptions.new(
|
16
|
+
:algorithm => Pandexio::SigningAlgorithms::PDX_HMAC_SHA256,
|
17
|
+
:mechanism => Pandexio::SigningMechanisms::QUERY_STRING,
|
18
|
+
:domain_id => "1234567890",
|
19
|
+
:domain_key => "asdfjklqwerzxcv",
|
20
|
+
:date => date,
|
21
|
+
:expires => 90,
|
22
|
+
:originator => "QueryStringSigningTest",
|
23
|
+
:email_address => "Anonymous",
|
24
|
+
:display_name => "Anonymous")
|
25
|
+
|
26
|
+
@authorized_request = Pandexio::to_authorized_request(normalized_request, signing_options)
|
27
|
+
@authorized_request = Pandexio::to_authorized_request(normalized_request, signing_options)
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "#query_string_signing" do
|
31
|
+
it "returns the correct algorithm as a query parameter" do
|
32
|
+
@authorized_request.query_parameters["X-Pdx-Algorithm"].must_equal "PDX-HMAC-SHA256"
|
33
|
+
end
|
34
|
+
it "returns the correct credential as a query parameter" do
|
35
|
+
@authorized_request.query_parameters["X-Pdx-Credential"].must_equal "1234567890"
|
36
|
+
end
|
37
|
+
it "returns the correct signed_headers value as a query parameter" do
|
38
|
+
@authorized_request.query_parameters["X-Pdx-SignedHeaders"].must_equal "host;sample"
|
39
|
+
end
|
40
|
+
it "returns the correct signature as a query parameter" do
|
41
|
+
@authorized_request.query_parameters["X-Pdx-Signature"].must_equal "6ab83c6a331ba2d684d2557f1e415f3aee86bee105da1f5ad1bc4cc1cdf42f1a"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
metadata
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pandexio
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brandon Varilone
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-06-10 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Pandexio SDK for Ruby
|
14
|
+
email: bvarilone@gmail.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/module.rb
|
20
|
+
- lib/request.rb
|
21
|
+
- lib/signing_algorithms.rb
|
22
|
+
- lib/signing_attributes.rb
|
23
|
+
- lib/signing_mechanisms.rb
|
24
|
+
- lib/signing_options.rb
|
25
|
+
- test/test_header_signing.rb
|
26
|
+
- test/test_query_string_signing.rb
|
27
|
+
homepage: http://rubygems.org/gems/pandexio
|
28
|
+
licenses:
|
29
|
+
- MIT
|
30
|
+
metadata: {}
|
31
|
+
post_install_message:
|
32
|
+
rdoc_options: []
|
33
|
+
require_paths:
|
34
|
+
- lib
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
requirements: []
|
46
|
+
rubyforge_project:
|
47
|
+
rubygems_version: 2.4.4
|
48
|
+
signing_key:
|
49
|
+
specification_version: 4
|
50
|
+
summary: ''
|
51
|
+
test_files: []
|