rubyntlm 0.6.1 → 0.6.2
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/.gitignore +4 -3
- data/.rspec +2 -2
- data/.travis.yml +13 -12
- data/CHANGELOG.md +118 -6
- data/Gemfile +3 -3
- data/LICENSE +19 -19
- data/Rakefile +25 -22
- data/lib/net/ntlm.rb +266 -266
- data/lib/net/ntlm/blob.rb +28 -28
- data/lib/net/ntlm/channel_binding.rb +65 -65
- data/lib/net/ntlm/client.rb +65 -65
- data/lib/net/ntlm/client/session.rb +237 -237
- data/lib/net/ntlm/encode_util.rb +48 -48
- data/lib/net/ntlm/exceptions.rb +14 -14
- data/lib/net/ntlm/field.rb +34 -34
- data/lib/net/ntlm/field_set.rb +129 -129
- data/lib/net/ntlm/int16_le.rb +25 -25
- data/lib/net/ntlm/int32_le.rb +24 -24
- data/lib/net/ntlm/int64_le.rb +25 -25
- data/lib/net/ntlm/message.rb +129 -129
- data/lib/net/ntlm/message/type0.rb +16 -16
- data/lib/net/ntlm/message/type1.rb +18 -18
- data/lib/net/ntlm/message/type2.rb +102 -102
- data/lib/net/ntlm/message/type3.rb +131 -131
- data/lib/net/ntlm/security_buffer.rb +47 -47
- data/lib/net/ntlm/string.rb +34 -34
- data/lib/net/ntlm/target_info.rb +89 -89
- data/lib/net/ntlm/version.rb +11 -11
- data/rubyntlm.gemspec +29 -28
- data/spec/lib/net/ntlm/blob_spec.rb +16 -16
- data/spec/lib/net/ntlm/channel_binding_spec.rb +17 -17
- data/spec/lib/net/ntlm/client/session_spec.rb +68 -68
- data/spec/lib/net/ntlm/client_spec.rb +64 -64
- data/spec/lib/net/ntlm/encode_util_spec.rb +16 -16
- data/spec/lib/net/ntlm/field_set_spec.rb +33 -33
- data/spec/lib/net/ntlm/field_spec.rb +34 -34
- data/spec/lib/net/ntlm/int16_le_spec.rb +17 -17
- data/spec/lib/net/ntlm/int32_le_spec.rb +18 -18
- data/spec/lib/net/ntlm/int64_le_spec.rb +18 -18
- data/spec/lib/net/ntlm/message/type0_spec.rb +20 -20
- data/spec/lib/net/ntlm/message/type1_spec.rb +131 -131
- data/spec/lib/net/ntlm/message/type2_spec.rb +132 -132
- data/spec/lib/net/ntlm/message/type3_spec.rb +225 -225
- data/spec/lib/net/ntlm/message_spec.rb +16 -16
- data/spec/lib/net/ntlm/security_buffer_spec.rb +64 -64
- data/spec/lib/net/ntlm/string_spec.rb +72 -72
- data/spec/lib/net/ntlm/target_info_spec.rb +76 -76
- data/spec/lib/net/ntlm/version_spec.rb +27 -27
- data/spec/lib/net/ntlm_spec.rb +127 -127
- data/spec/spec_helper.rb +22 -22
- data/spec/support/certificates/sha_256_hash.pem +19 -19
- data/spec/support/shared/examples/net/ntlm/field_shared.rb +25 -25
- data/spec/support/shared/examples/net/ntlm/fieldset_shared.rb +239 -239
- data/spec/support/shared/examples/net/ntlm/int_shared.rb +43 -43
- data/spec/support/shared/examples/net/ntlm/message_shared.rb +35 -35
- metadata +17 -3
@@ -1,48 +1,48 @@
|
|
1
|
-
|
2
|
-
module Net
|
3
|
-
module NTLM
|
4
|
-
|
5
|
-
class SecurityBuffer < FieldSet
|
6
|
-
|
7
|
-
int16LE :length, {:value => 0}
|
8
|
-
int16LE :allocated, {:value => 0}
|
9
|
-
int32LE :offset, {:value => 0}
|
10
|
-
|
11
|
-
attr_accessor :active
|
12
|
-
def initialize(opts={})
|
13
|
-
super()
|
14
|
-
@value = opts[:value]
|
15
|
-
@active = opts[:active].nil? ? true : opts[:active]
|
16
|
-
@size = 8
|
17
|
-
end
|
18
|
-
|
19
|
-
def parse(str, offset=0)
|
20
|
-
if @active and str.size >= offset + @size
|
21
|
-
super(str, offset)
|
22
|
-
@value = str[self.offset, self.length]
|
23
|
-
@size
|
24
|
-
else
|
25
|
-
0
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def serialize
|
30
|
-
super if @active
|
31
|
-
end
|
32
|
-
|
33
|
-
def value
|
34
|
-
@value
|
35
|
-
end
|
36
|
-
|
37
|
-
def value=(val)
|
38
|
-
@value = val
|
39
|
-
self.length = self.allocated = val.size
|
40
|
-
end
|
41
|
-
|
42
|
-
def data_size
|
43
|
-
@active ? @value.size : 0
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
end
|
1
|
+
|
2
|
+
module Net
|
3
|
+
module NTLM
|
4
|
+
|
5
|
+
class SecurityBuffer < FieldSet
|
6
|
+
|
7
|
+
int16LE :length, {:value => 0}
|
8
|
+
int16LE :allocated, {:value => 0}
|
9
|
+
int32LE :offset, {:value => 0}
|
10
|
+
|
11
|
+
attr_accessor :active
|
12
|
+
def initialize(opts={})
|
13
|
+
super()
|
14
|
+
@value = opts[:value]
|
15
|
+
@active = opts[:active].nil? ? true : opts[:active]
|
16
|
+
@size = 8
|
17
|
+
end
|
18
|
+
|
19
|
+
def parse(str, offset=0)
|
20
|
+
if @active and str.size >= offset + @size
|
21
|
+
super(str, offset)
|
22
|
+
@value = str[self.offset, self.length]
|
23
|
+
@size
|
24
|
+
else
|
25
|
+
0
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def serialize
|
30
|
+
super if @active
|
31
|
+
end
|
32
|
+
|
33
|
+
def value
|
34
|
+
@value
|
35
|
+
end
|
36
|
+
|
37
|
+
def value=(val)
|
38
|
+
@value = val
|
39
|
+
self.length = self.allocated = val.size
|
40
|
+
end
|
41
|
+
|
42
|
+
def data_size
|
43
|
+
@active ? @value.size : 0
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
48
|
end
|
data/lib/net/ntlm/string.rb
CHANGED
@@ -1,35 +1,35 @@
|
|
1
|
-
module Net
|
2
|
-
module NTLM
|
3
|
-
|
4
|
-
class String < Field
|
5
|
-
def initialize(opts)
|
6
|
-
super(opts)
|
7
|
-
@size = opts[:size]
|
8
|
-
end
|
9
|
-
|
10
|
-
def parse(str, offset=0)
|
11
|
-
if @active and str.size >= offset + @size
|
12
|
-
@value = str[offset, @size]
|
13
|
-
@size
|
14
|
-
else
|
15
|
-
0
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
def serialize
|
20
|
-
if @active
|
21
|
-
@value.to_s
|
22
|
-
else
|
23
|
-
""
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def value=(val)
|
28
|
-
@value = val
|
29
|
-
@size = @value.nil? ? 0 : @value.size
|
30
|
-
@active = (@size > 0)
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
end
|
1
|
+
module Net
|
2
|
+
module NTLM
|
3
|
+
|
4
|
+
class String < Field
|
5
|
+
def initialize(opts)
|
6
|
+
super(opts)
|
7
|
+
@size = opts[:size]
|
8
|
+
end
|
9
|
+
|
10
|
+
def parse(str, offset=0)
|
11
|
+
if @active and str.size >= offset + @size
|
12
|
+
@value = str[offset, @size]
|
13
|
+
@size
|
14
|
+
else
|
15
|
+
0
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def serialize
|
20
|
+
if @active
|
21
|
+
@value.to_s
|
22
|
+
else
|
23
|
+
""
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def value=(val)
|
28
|
+
@value = val
|
29
|
+
@size = @value.nil? ? 0 : @value.size
|
30
|
+
@active = (@size > 0)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
35
|
end
|
data/lib/net/ntlm/target_info.rb
CHANGED
@@ -1,89 +1,89 @@
|
|
1
|
-
module Net
|
2
|
-
module NTLM
|
3
|
-
|
4
|
-
# Represents a list of AV_PAIR structures
|
5
|
-
# @see https://msdn.microsoft.com/en-us/library/cc236646.aspx
|
6
|
-
class TargetInfo
|
7
|
-
|
8
|
-
# Allowed AvId values for an AV_PAIR
|
9
|
-
MSV_AV_EOL = "\x00\x00".freeze
|
10
|
-
MSV_AV_NB_COMPUTER_NAME = "\x01\x00".freeze
|
11
|
-
MSV_AV_NB_DOMAIN_NAME = "\x02\x00".freeze
|
12
|
-
MSV_AV_DNS_COMPUTER_NAME = "\x03\x00".freeze
|
13
|
-
MSV_AV_DNS_DOMAIN_NAME = "\x04\x00".freeze
|
14
|
-
MSV_AV_DNS_TREE_NAME = "\x05\x00".freeze
|
15
|
-
MSV_AV_FLAGS = "\x06\x00".freeze
|
16
|
-
MSV_AV_TIMESTAMP = "\x07\x00".freeze
|
17
|
-
MSV_AV_SINGLE_HOST = "\x08\x00".freeze
|
18
|
-
MSV_AV_TARGET_NAME = "\x09\x00".freeze
|
19
|
-
MSV_AV_CHANNEL_BINDINGS = "\x0A\x00".freeze
|
20
|
-
|
21
|
-
# @param av_pair_sequence [String] AV_PAIR list from challenge message
|
22
|
-
def initialize(av_pair_sequence)
|
23
|
-
@av_pairs = read_pairs(av_pair_sequence)
|
24
|
-
end
|
25
|
-
|
26
|
-
attr_reader :av_pairs
|
27
|
-
|
28
|
-
def to_s
|
29
|
-
result = ''
|
30
|
-
av_pairs.each do |k,v|
|
31
|
-
result << k
|
32
|
-
result << [v.length].pack('S')
|
33
|
-
result << v
|
34
|
-
end
|
35
|
-
result << Net::NTLM::TargetInfo::MSV_AV_EOL
|
36
|
-
result << [0].pack('S')
|
37
|
-
result.force_encoding(Encoding::ASCII_8BIT)
|
38
|
-
end
|
39
|
-
|
40
|
-
private
|
41
|
-
|
42
|
-
VALID_PAIR_ID = [
|
43
|
-
MSV_AV_EOL,
|
44
|
-
MSV_AV_NB_COMPUTER_NAME,
|
45
|
-
MSV_AV_NB_DOMAIN_NAME,
|
46
|
-
MSV_AV_DNS_COMPUTER_NAME,
|
47
|
-
MSV_AV_DNS_DOMAIN_NAME,
|
48
|
-
MSV_AV_DNS_TREE_NAME,
|
49
|
-
MSV_AV_FLAGS,
|
50
|
-
MSV_AV_TIMESTAMP,
|
51
|
-
MSV_AV_SINGLE_HOST,
|
52
|
-
MSV_AV_TARGET_NAME,
|
53
|
-
MSV_AV_CHANNEL_BINDINGS
|
54
|
-
].freeze
|
55
|
-
|
56
|
-
def read_pairs(av_pair_sequence)
|
57
|
-
offset = 0
|
58
|
-
result = {}
|
59
|
-
return result if av_pair_sequence.nil?
|
60
|
-
|
61
|
-
until offset >= av_pair_sequence.length
|
62
|
-
id = av_pair_sequence[offset..offset+1]
|
63
|
-
|
64
|
-
unless VALID_PAIR_ID.include?(id)
|
65
|
-
raise Net::NTLM::InvalidTargetDataError.new(
|
66
|
-
"Invalid AvId #{to_hex(id)} in AV_PAIR structure",
|
67
|
-
av_pair_sequence
|
68
|
-
)
|
69
|
-
end
|
70
|
-
|
71
|
-
length = av_pair_sequence[offset+2..offset+3].unpack('S')[0].to_i
|
72
|
-
if length > 0
|
73
|
-
value = av_pair_sequence[offset+4..offset+4+length-1]
|
74
|
-
result[id] = value
|
75
|
-
end
|
76
|
-
|
77
|
-
offset += 4 + length
|
78
|
-
end
|
79
|
-
|
80
|
-
result
|
81
|
-
end
|
82
|
-
|
83
|
-
def to_hex(str)
|
84
|
-
return nil if str.nil?
|
85
|
-
str.bytes.map {|b| '0x' + b.to_s(16).rjust(2,'0').upcase}.join('-')
|
86
|
-
end
|
87
|
-
end
|
88
|
-
end
|
89
|
-
end
|
1
|
+
module Net
|
2
|
+
module NTLM
|
3
|
+
|
4
|
+
# Represents a list of AV_PAIR structures
|
5
|
+
# @see https://msdn.microsoft.com/en-us/library/cc236646.aspx
|
6
|
+
class TargetInfo
|
7
|
+
|
8
|
+
# Allowed AvId values for an AV_PAIR
|
9
|
+
MSV_AV_EOL = "\x00\x00".freeze
|
10
|
+
MSV_AV_NB_COMPUTER_NAME = "\x01\x00".freeze
|
11
|
+
MSV_AV_NB_DOMAIN_NAME = "\x02\x00".freeze
|
12
|
+
MSV_AV_DNS_COMPUTER_NAME = "\x03\x00".freeze
|
13
|
+
MSV_AV_DNS_DOMAIN_NAME = "\x04\x00".freeze
|
14
|
+
MSV_AV_DNS_TREE_NAME = "\x05\x00".freeze
|
15
|
+
MSV_AV_FLAGS = "\x06\x00".freeze
|
16
|
+
MSV_AV_TIMESTAMP = "\x07\x00".freeze
|
17
|
+
MSV_AV_SINGLE_HOST = "\x08\x00".freeze
|
18
|
+
MSV_AV_TARGET_NAME = "\x09\x00".freeze
|
19
|
+
MSV_AV_CHANNEL_BINDINGS = "\x0A\x00".freeze
|
20
|
+
|
21
|
+
# @param av_pair_sequence [String] AV_PAIR list from challenge message
|
22
|
+
def initialize(av_pair_sequence)
|
23
|
+
@av_pairs = read_pairs(av_pair_sequence)
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_reader :av_pairs
|
27
|
+
|
28
|
+
def to_s
|
29
|
+
result = ''
|
30
|
+
av_pairs.each do |k,v|
|
31
|
+
result << k
|
32
|
+
result << [v.length].pack('S')
|
33
|
+
result << v
|
34
|
+
end
|
35
|
+
result << Net::NTLM::TargetInfo::MSV_AV_EOL
|
36
|
+
result << [0].pack('S')
|
37
|
+
result.force_encoding(Encoding::ASCII_8BIT)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
VALID_PAIR_ID = [
|
43
|
+
MSV_AV_EOL,
|
44
|
+
MSV_AV_NB_COMPUTER_NAME,
|
45
|
+
MSV_AV_NB_DOMAIN_NAME,
|
46
|
+
MSV_AV_DNS_COMPUTER_NAME,
|
47
|
+
MSV_AV_DNS_DOMAIN_NAME,
|
48
|
+
MSV_AV_DNS_TREE_NAME,
|
49
|
+
MSV_AV_FLAGS,
|
50
|
+
MSV_AV_TIMESTAMP,
|
51
|
+
MSV_AV_SINGLE_HOST,
|
52
|
+
MSV_AV_TARGET_NAME,
|
53
|
+
MSV_AV_CHANNEL_BINDINGS
|
54
|
+
].freeze
|
55
|
+
|
56
|
+
def read_pairs(av_pair_sequence)
|
57
|
+
offset = 0
|
58
|
+
result = {}
|
59
|
+
return result if av_pair_sequence.nil?
|
60
|
+
|
61
|
+
until offset >= av_pair_sequence.length
|
62
|
+
id = av_pair_sequence[offset..offset+1]
|
63
|
+
|
64
|
+
unless VALID_PAIR_ID.include?(id)
|
65
|
+
raise Net::NTLM::InvalidTargetDataError.new(
|
66
|
+
"Invalid AvId #{to_hex(id)} in AV_PAIR structure",
|
67
|
+
av_pair_sequence
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
length = av_pair_sequence[offset+2..offset+3].unpack('S')[0].to_i
|
72
|
+
if length > 0
|
73
|
+
value = av_pair_sequence[offset+4..offset+4+length-1]
|
74
|
+
result[id] = value
|
75
|
+
end
|
76
|
+
|
77
|
+
offset += 4 + length
|
78
|
+
end
|
79
|
+
|
80
|
+
result
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_hex(str)
|
84
|
+
return nil if str.nil?
|
85
|
+
str.bytes.map {|b| '0x' + b.to_s(16).rjust(2,'0').upcase}.join('-')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/lib/net/ntlm/version.rb
CHANGED
@@ -1,11 +1,11 @@
|
|
1
|
-
module Net
|
2
|
-
module NTLM
|
3
|
-
# @private
|
4
|
-
module VERSION
|
5
|
-
MAJOR = 0
|
6
|
-
MINOR = 6
|
7
|
-
TINY =
|
8
|
-
STRING = [MAJOR, MINOR, TINY].join('.')
|
9
|
-
end
|
10
|
-
end
|
11
|
-
end
|
1
|
+
module Net
|
2
|
+
module NTLM
|
3
|
+
# @private
|
4
|
+
module VERSION
|
5
|
+
MAJOR = 0
|
6
|
+
MINOR = 6
|
7
|
+
TINY = 2
|
8
|
+
STRING = [MAJOR, MINOR, TINY].join('.')
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
data/rubyntlm.gemspec
CHANGED
@@ -1,28 +1,29 @@
|
|
1
|
-
require File.join(File.dirname(__FILE__), 'lib', 'net', 'ntlm', 'version')
|
2
|
-
|
3
|
-
Gem::Specification.new do |s|
|
4
|
-
s.platform = Gem::Platform::RUBY
|
5
|
-
s.name = 'rubyntlm'
|
6
|
-
s.version = Net::NTLM::VERSION::STRING
|
7
|
-
s.summary = 'Ruby/NTLM library.'
|
8
|
-
s.description = 'Ruby/NTLM provides message creator and parser for the NTLM authentication.'
|
9
|
-
|
10
|
-
s.authors = ['Kohei Kajimoto','Paul Morton']
|
11
|
-
s.email = ['koheik@gmail.com','paul.e.morton@gmail.com']
|
12
|
-
s.homepage = 'https://github.com/winrb/rubyntlm'
|
13
|
-
|
14
|
-
|
15
|
-
s.files = `git ls-files`.split($/)
|
16
|
-
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
-
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
18
|
-
s.require_paths = ["lib"]
|
19
|
-
|
20
|
-
s.required_ruby_version = '>= 1.8.7'
|
21
|
-
|
22
|
-
s.license = 'MIT'
|
23
|
-
|
24
|
-
s.add_development_dependency
|
25
|
-
s.add_development_dependency "
|
26
|
-
s.add_development_dependency "
|
27
|
-
s.add_development_dependency "
|
28
|
-
|
1
|
+
require File.join(File.dirname(__FILE__), 'lib', 'net', 'ntlm', 'version')
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.platform = Gem::Platform::RUBY
|
5
|
+
s.name = 'rubyntlm'
|
6
|
+
s.version = Net::NTLM::VERSION::STRING
|
7
|
+
s.summary = 'Ruby/NTLM library.'
|
8
|
+
s.description = 'Ruby/NTLM provides message creator and parser for the NTLM authentication.'
|
9
|
+
|
10
|
+
s.authors = ['Kohei Kajimoto','Paul Morton']
|
11
|
+
s.email = ['koheik@gmail.com','paul.e.morton@gmail.com']
|
12
|
+
s.homepage = 'https://github.com/winrb/rubyntlm'
|
13
|
+
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split($/)
|
16
|
+
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
|
20
|
+
s.required_ruby_version = '>= 1.8.7'
|
21
|
+
|
22
|
+
s.license = 'MIT'
|
23
|
+
|
24
|
+
s.add_development_dependency 'github_changelog_generator', '1.14.3'
|
25
|
+
s.add_development_dependency "pry"
|
26
|
+
s.add_development_dependency "rake"
|
27
|
+
s.add_development_dependency "rspec", ">= 2.11"
|
28
|
+
s.add_development_dependency "simplecov"
|
29
|
+
end
|
@@ -1,16 +1,16 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe Net::NTLM::Blob do
|
4
|
-
|
5
|
-
fields = [
|
6
|
-
{ :name => :blob_signature, :class => Net::NTLM::Int32LE, :value => 257, :active => true },
|
7
|
-
{ :name => :reserved, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
8
|
-
{ :name => :timestamp, :class => Net::NTLM::Int64LE, :value => 0, :active => true },
|
9
|
-
{ :name => :challenge, :class => Net::NTLM::String, :value => '', :active => true },
|
10
|
-
{ :name => :unknown1, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
11
|
-
{ :name => :target_info, :class => Net::NTLM::String, :value => '', :active => true },
|
12
|
-
{ :name => :unknown2, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
13
|
-
]
|
14
|
-
|
15
|
-
it_behaves_like 'a fieldset', fields
|
16
|
-
end
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Net::NTLM::Blob do
|
4
|
+
|
5
|
+
fields = [
|
6
|
+
{ :name => :blob_signature, :class => Net::NTLM::Int32LE, :value => 257, :active => true },
|
7
|
+
{ :name => :reserved, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
8
|
+
{ :name => :timestamp, :class => Net::NTLM::Int64LE, :value => 0, :active => true },
|
9
|
+
{ :name => :challenge, :class => Net::NTLM::String, :value => '', :active => true },
|
10
|
+
{ :name => :unknown1, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
11
|
+
{ :name => :target_info, :class => Net::NTLM::String, :value => '', :active => true },
|
12
|
+
{ :name => :unknown2, :class => Net::NTLM::Int32LE, :value => 0, :active => true },
|
13
|
+
]
|
14
|
+
|
15
|
+
it_behaves_like 'a fieldset', fields
|
16
|
+
end
|