ape 1.0.0 → 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +1 -1
- data/README +22 -8
- data/Rakefile +66 -0
- data/bin/ape_server +3 -3
- data/lib/ape.rb +131 -937
- data/lib/ape/atomURI.rb +1 -1
- data/lib/ape/authent.rb +11 -17
- data/lib/ape/categories.rb +8 -7
- data/lib/ape/collection.rb +3 -7
- data/lib/ape/crumbs.rb +1 -1
- data/lib/ape/entry.rb +1 -1
- data/lib/ape/escaper.rb +1 -1
- data/lib/ape/feed.rb +26 -14
- data/lib/ape/handler.rb +8 -3
- data/lib/ape/html.rb +1 -1
- data/lib/ape/invoker.rb +1 -1
- data/lib/ape/invokers/deleter.rb +1 -1
- data/lib/ape/invokers/getter.rb +1 -1
- data/lib/ape/invokers/poster.rb +1 -1
- data/lib/ape/invokers/putter.rb +1 -1
- data/lib/ape/names.rb +1 -1
- data/lib/ape/print_writer.rb +4 -6
- data/lib/ape/reporter.rb +156 -0
- data/lib/ape/reporters/atom_reporter.rb +51 -0
- data/lib/ape/reporters/atom_template.eruby +38 -0
- data/lib/ape/reporters/html_reporter.rb +53 -0
- data/lib/ape/reporters/html_template.eruby +62 -0
- data/lib/ape/reporters/text_reporter.rb +37 -0
- data/lib/ape/samples.rb +30 -51
- data/lib/ape/server.rb +16 -4
- data/lib/ape/service.rb +1 -1
- data/lib/ape/util.rb +67 -0
- data/lib/ape/validator.rb +85 -57
- data/lib/ape/validator_dsl.rb +40 -0
- data/lib/ape/validators/entry_posts_validator.rb +226 -0
- data/lib/ape/validators/media_linkage_validator.rb +78 -0
- data/lib/ape/validators/media_posts_validator.rb +104 -0
- data/lib/ape/validators/sanitization_validator.rb +57 -0
- data/lib/ape/validators/schema_validator.rb +57 -0
- data/lib/ape/validators/service_document_validator.rb +64 -0
- data/lib/ape/validators/sorting_validator.rb +87 -0
- data/lib/ape/version.rb +1 -1
- data/{lib/ape/samples → samples}/atom_schema.txt +0 -0
- data/{lib/ape/samples → samples}/basic_entry.eruby +4 -4
- data/{lib/ape/samples → samples}/categories_schema.txt +0 -0
- data/{lib/ape/samples → samples}/mini_entry.eruby +0 -0
- data/{lib/ape/samples → samples}/service_schema.txt +0 -0
- data/{lib/ape/samples → samples}/unclean_xhtml_entry.eruby +0 -0
- data/test/test_helper.rb +33 -1
- data/test/unit/ape_test.rb +92 -0
- data/test/unit/authent_test.rb +2 -2
- data/test/unit/reporter_test.rb +102 -0
- data/test/unit/samples_test.rb +2 -2
- data/test/unit/validators_test.rb +50 -0
- data/{lib/ape/layout → web}/ape.css +5 -1
- data/{lib/ape/layout → web}/ape_logo.png +0 -0
- data/{lib/ape/layout → web}/index.html +2 -5
- data/{lib/ape/layout → web}/info.png +0 -0
- metadata +108 -56
- data/CHANGELOG +0 -1
- data/Manifest +0 -45
- data/ape.gemspec +0 -57
- data/scripts/go.rb +0 -29
@@ -0,0 +1,104 @@
|
|
1
|
+
module Ape
|
2
|
+
class MediaPostsValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :media_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
reporter.info(self, "TESTING: Posting to media collection.")
|
8
|
+
media_collection = opts[:media_collection]
|
9
|
+
reporter.info(self, "Will use collection '#{media_collection.title}' for media creation.")
|
10
|
+
|
11
|
+
# * Post a picture to the media collection
|
12
|
+
#
|
13
|
+
poster = Poster.new(media_collection.href, @authent)
|
14
|
+
if poster.last_error
|
15
|
+
reporter.error(self, "Unacceptable URI for '#{media_coll.title}' collection: " +
|
16
|
+
poster.last_error)
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
name = 'Post image to media collection'
|
21
|
+
|
22
|
+
# ask it to use this in the URI
|
23
|
+
slug_num = rand(100000)
|
24
|
+
slug = "apix-#{slug_num}"
|
25
|
+
slug_re = %r{apix.?#{slug_num}}
|
26
|
+
poster.set_header('Slug', slug)
|
27
|
+
|
28
|
+
#poster.set_header('Slug', slug)
|
29
|
+
worked = poster.post('image/jpeg', Samples.picture)
|
30
|
+
reporter.save_dialog(name, poster)
|
31
|
+
if !worked
|
32
|
+
reporter.error(self, "Can't POST picture to media collection: #{poster.last_error}",
|
33
|
+
name)
|
34
|
+
return
|
35
|
+
end
|
36
|
+
|
37
|
+
reporter.success(self, "Post of image file reported success, media link location: " +
|
38
|
+
"#{poster.header('Location')}", name)
|
39
|
+
|
40
|
+
# * Retrieve the media link entry
|
41
|
+
mle_uri = poster.header('Location')
|
42
|
+
|
43
|
+
media_link_entry = check_resource(mle_uri, 'Retrieval of media link entry', Names::AtomMediaType)
|
44
|
+
return unless media_link_entry
|
45
|
+
|
46
|
+
if media_link_entry.last_error
|
47
|
+
reporter.error(self, "Can't proceed with media-post testing.")
|
48
|
+
return
|
49
|
+
end
|
50
|
+
|
51
|
+
# * See if the <content src= is there and usable
|
52
|
+
begin
|
53
|
+
media_link_entry = Entry.new(media_link_entry.body, mle_uri)
|
54
|
+
rescue REXML::ParseException
|
55
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
56
|
+
reporter.error(self, "Media link entry is not well-formed: #{prob}")
|
57
|
+
return
|
58
|
+
end
|
59
|
+
content_src = media_link_entry.content_src
|
60
|
+
if (!content_src) || (content_src == "")
|
61
|
+
reporter.error(self, "Media link entry has no content@src pointer to media resource.")
|
62
|
+
return
|
63
|
+
end
|
64
|
+
|
65
|
+
# see if slug was used in media URI
|
66
|
+
if content_src =~ slug_re
|
67
|
+
reporter.success(self, "Client-provided slug '#{slug}' was used in Media Resource URI.")
|
68
|
+
else
|
69
|
+
reporter.warning(self, "Client-provided slug '#{slug}' not used in Media Resource URI.")
|
70
|
+
end
|
71
|
+
|
72
|
+
media_link_id = media_link_entry.child_content('id')
|
73
|
+
|
74
|
+
name = 'Retrieval of media resource'
|
75
|
+
picture = check_resource(content_src, name, 'image/jpeg')
|
76
|
+
return unless picture
|
77
|
+
|
78
|
+
if picture.body == Samples.picture
|
79
|
+
reporter.success(self, "Media resource was apparently stored and retrieved properly.")
|
80
|
+
else
|
81
|
+
reporter.warning(self, "Media resource differs from posted picture")
|
82
|
+
end
|
83
|
+
|
84
|
+
# * Delete the media link entry
|
85
|
+
return unless delete_entry(media_link_entry, 'Deletion of media link entry')
|
86
|
+
|
87
|
+
# * media link entry still in feed?
|
88
|
+
still_there = find_entry(media_collection.href, "media collection", media_link_id)
|
89
|
+
if still_there.class != String
|
90
|
+
reporter.error(self, "Media link entry is still in collection post-deletion.")
|
91
|
+
else
|
92
|
+
reporter.success(self, "Media link entry no longer in feed.")
|
93
|
+
end
|
94
|
+
|
95
|
+
# is the resource there any more?
|
96
|
+
name = 'Check Media Resource deletion'
|
97
|
+
if check_resource(content_src, name, 'image/jpeg', false)
|
98
|
+
reporter.error(self, "Media resource still there after media link entry deletion.")
|
99
|
+
else
|
100
|
+
reporter.success(self, "Media resource no longer fetchable.")
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Ape
|
2
|
+
class SanitizationValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :entry_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
reporter.info(self, "TESTING: Content sanitization")
|
8
|
+
coll = opts[:entry_collection]
|
9
|
+
|
10
|
+
poster = Poster.new(coll.href, @authent)
|
11
|
+
name = 'Posting unclean XHTML'
|
12
|
+
worked = poster.post(Names::AtomEntryMediaType, Samples.unclean_xhtml_entry)
|
13
|
+
if !worked
|
14
|
+
reporter.save_dialog(name, poster)
|
15
|
+
reporter.error(self, "Can't POST unclean XHTML: #{poster.last_error}", name)
|
16
|
+
return
|
17
|
+
end
|
18
|
+
|
19
|
+
location = poster.header('Location')
|
20
|
+
name = "Retrieval of unclean XHTML entry"
|
21
|
+
entry = check_resource(location, name, Names::AtomMediaType)
|
22
|
+
return unless entry
|
23
|
+
|
24
|
+
begin
|
25
|
+
entry = Entry.new(entry.body, location)
|
26
|
+
rescue REXML::ParseException
|
27
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
28
|
+
reporter.error(self, "New entry is not well-formed: #{prob}")
|
29
|
+
return
|
30
|
+
end
|
31
|
+
|
32
|
+
no_problem = true
|
33
|
+
patterns = {
|
34
|
+
'//xhtml:script' => "Published entry retains xhtml:script element.",
|
35
|
+
'//*[@background]' => "Published entry retains 'background' attribute.",
|
36
|
+
'//*[@style]' => "Published entry retains 'style' attribute.",
|
37
|
+
|
38
|
+
}
|
39
|
+
patterns.each { |xp, message|
|
40
|
+
reporter.warning(self, message) unless entry.xpath_match(xp).empty?
|
41
|
+
}
|
42
|
+
|
43
|
+
entry.xpath_match('//xhtml:a').each do |a|
|
44
|
+
if a.attributes['href'] =~ /^([a-zA-Z]+):/
|
45
|
+
if $1 != 'http'
|
46
|
+
no_problem = false
|
47
|
+
reporter.warning(self, "Published entry retains dangerous hyperlink: '#{a.attributes['href']}'.")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
delete_entry(entry)
|
53
|
+
|
54
|
+
reporter.success(self, "Published entry appears to be sanitized.") if no_problem
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
if RUBY_PLATFORM =~ /java/
|
2
|
+
require 'java'
|
3
|
+
CompactSchemaReader = com.thaiopensource.validate.rng.CompactSchemaReader
|
4
|
+
ValidationDriver = com.thaiopensource.validate.ValidationDriver
|
5
|
+
StringReader = java.io.StringReader
|
6
|
+
StringWriter = java.io.StringWriter
|
7
|
+
InputSource = org.xml.sax.InputSource
|
8
|
+
ErrorHandlerImpl = com.thaiopensource.xml.sax.ErrorHandlerImpl
|
9
|
+
PropertyMapBuilder = com.thaiopensource.util.PropertyMapBuilder
|
10
|
+
ValidateProperty = com.thaiopensource.validate.ValidateProperty
|
11
|
+
end
|
12
|
+
|
13
|
+
module Ape
|
14
|
+
class SchemaValidator < Validator
|
15
|
+
disabled
|
16
|
+
def validate(opts = {})
|
17
|
+
if RUBY_PLATFORM =~ /java/
|
18
|
+
rcn_validate(opts[:schema], opts[:doc].body, opts[:title])
|
19
|
+
else
|
20
|
+
reporter.add(self, :info, "Schema validation is just available building the ape with jruby.")
|
21
|
+
true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def rnc_validate(schema, text, name, ape)
|
26
|
+
schemaError = StringWriter.new
|
27
|
+
schemaEH = ErrorHandlerImpl.new(schemaError)
|
28
|
+
properties = PropertyMapBuilder.new
|
29
|
+
properties.put(ValidateProperty::ERROR_HANDLER, schemaEH)
|
30
|
+
error = nil
|
31
|
+
driver = ValidationDriver.new(properties.toPropertyMap, CompactSchemaReader.getInstance)
|
32
|
+
if driver.loadSchema(InputSource.new(StringReader.new(schema)))
|
33
|
+
begin
|
34
|
+
if !driver.validate(InputSource.new(StringReader.new(text)))
|
35
|
+
error = schemaError.toString
|
36
|
+
end
|
37
|
+
rescue org.xml.sax.SAXParseException
|
38
|
+
error = $!.to_s.sub(/\n.*$/, '')
|
39
|
+
end
|
40
|
+
else
|
41
|
+
error = schemaError.toString
|
42
|
+
end
|
43
|
+
|
44
|
+
if !error
|
45
|
+
reporter.add(self, :success, "#{name} passed schema validation.")
|
46
|
+
true
|
47
|
+
else
|
48
|
+
# this kind of sucks, but I spent a looong time lost in a maze of twisty
|
49
|
+
# little passages without being able to figure out how to
|
50
|
+
# tell jing what name I'd like to call the InputSource
|
51
|
+
reporter.add(self, :error, "#{name} failed schema validation:\n" + error.gsub('(unknown file):', 'Line '))
|
52
|
+
false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Ape
|
2
|
+
class ServiceDocumentValidator < Validator
|
3
|
+
disabled
|
4
|
+
deterministic
|
5
|
+
attr_reader :service_document, :entry_collections, :media_collections
|
6
|
+
|
7
|
+
def validate(opts = {})
|
8
|
+
init_service_document(opts[:uri])
|
9
|
+
raise ValidationError, "service document not found in: #{opts[uri]}" unless @service
|
10
|
+
init_service_collections(opts[:uri])
|
11
|
+
raise ValidationError unless @entry_collections && @media_collections
|
12
|
+
end
|
13
|
+
|
14
|
+
def init_service_document(uri)
|
15
|
+
reporter.info(self, "TESTING: Service document and collections.")
|
16
|
+
name = 'Retrieval of Service Document'
|
17
|
+
service = check_resource(uri, name, Names::AppMediaType)
|
18
|
+
return unless service
|
19
|
+
|
20
|
+
# * XML-parse the service doc
|
21
|
+
text = service.body
|
22
|
+
begin
|
23
|
+
@service = REXML::Document.new(text, { :raw => nil })
|
24
|
+
rescue REXML::ParseException
|
25
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
26
|
+
reporter.error(self, "Service document not well-formed: #{prob}")
|
27
|
+
return
|
28
|
+
end
|
29
|
+
|
30
|
+
# RNC-validate the service doc
|
31
|
+
Validator.instance(:schema, @reporter).validate(:schema => Samples.service_RNC,
|
32
|
+
:title => 'Service doc', :doc => @service)
|
33
|
+
end
|
34
|
+
|
35
|
+
def init_service_collections(uri)
|
36
|
+
# * Do we have collections we can post an entry and a picture to?
|
37
|
+
# the requested_* arguments are the requested collection titles; if
|
38
|
+
# provided, try to match them, otherwise just pick the first listed
|
39
|
+
#
|
40
|
+
begin
|
41
|
+
@service_collections = Service.collections(@service, uri)
|
42
|
+
rescue Exception
|
43
|
+
reporter.error(self, "Couldn't read collections from service doc: #{$!}")
|
44
|
+
return
|
45
|
+
end
|
46
|
+
|
47
|
+
if @service_collections.length > 0
|
48
|
+
reporter.start_list(self, "Found these collections")
|
49
|
+
@service_collections.each do |collection|
|
50
|
+
reporter.list_item("'#{collection.title}' " +
|
51
|
+
"accepts #{collection.accept.join(', ')}")
|
52
|
+
|
53
|
+
if (collection.accept.index(Names::AtomEntryMediaType))
|
54
|
+
@entry_collections ||= []
|
55
|
+
@entry_collections << collection
|
56
|
+
else
|
57
|
+
@media_collections ||= []
|
58
|
+
@media_collections << collection
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Ape
|
2
|
+
class SortingValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :entry_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
coll = opts[:entry_collection]
|
8
|
+
reporter.info(self, "TESTING: Collection re-ordering after PUT.")
|
9
|
+
|
10
|
+
# We'll post three mini entries to the collection
|
11
|
+
poster = Poster.new(coll.href, @authent)
|
12
|
+
['One', 'Two', 'Three'].each do |num|
|
13
|
+
sleep 2
|
14
|
+
text = Samples.mini_entry.gsub('Mini-1', "Mini #{num}")
|
15
|
+
name = "Posting Mini #{num}"
|
16
|
+
worked = poster.post(Names::AtomEntryMediaType, text)
|
17
|
+
reporter.save_dialog(name, poster)
|
18
|
+
if !worked
|
19
|
+
reporter.error(self, "Can't POST Mini #{name}: #{poster.last_error}", name)
|
20
|
+
return
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# now let's grab the collection & check the order
|
25
|
+
wanted = ['Mini One', 'Mini Two', 'Mini Three']
|
26
|
+
two = nil
|
27
|
+
entries = Feed.read(coll.href, 'Entries with multi-post', reporter)
|
28
|
+
entries.each do |from_feed|
|
29
|
+
want = wanted.pop
|
30
|
+
unless from_feed.child_content('title').index(want)
|
31
|
+
reporter.error(self, "Entries feed out of order after multi-post.")
|
32
|
+
return
|
33
|
+
end
|
34
|
+
two = from_feed if want == 'Mini Two'
|
35
|
+
break if wanted.empty?
|
36
|
+
end
|
37
|
+
reporter.success(self, "Entries correctly ordered after multi-post.")
|
38
|
+
|
39
|
+
# let's update one of them; have to fetch it first to get the ETag
|
40
|
+
link = two.link('edit', self)
|
41
|
+
unless link
|
42
|
+
reporter.error(self, "Can't check entry without edit link, entry id: #{two.get_child('id/text()')}")
|
43
|
+
return
|
44
|
+
end
|
45
|
+
two_resp = check_resource(link, 'fetch two', Names::AtomMediaType, false)
|
46
|
+
|
47
|
+
correctly_ordered = false
|
48
|
+
if two_resp
|
49
|
+
etag = two_resp.header 'etag'
|
50
|
+
|
51
|
+
putter = Putter.new(link, @authent)
|
52
|
+
putter.set_header('If-Match', etag)
|
53
|
+
|
54
|
+
name = 'Updating mini-entry with PUT'
|
55
|
+
sleep 2
|
56
|
+
updated = two_resp.body.gsub('Mini Two', 'Mini-4')
|
57
|
+
unless putter.put(Names::AtomEntryMediaType, updated)
|
58
|
+
reporter.save_dialog(name, putter)
|
59
|
+
reporter.error(self, "Can't update mini-entry at #{link}", name)
|
60
|
+
return
|
61
|
+
end
|
62
|
+
# now the order should have changed
|
63
|
+
wanted = ['Mini One', 'Mini Three', 'Mini-4']
|
64
|
+
correctly_ordered = true
|
65
|
+
else
|
66
|
+
reporter.error(self, "Mini Two entry not received. Can't assure the correct order after update.")
|
67
|
+
wanted = ['Mini One', 'Mini Two', 'Mini Three']
|
68
|
+
end
|
69
|
+
|
70
|
+
entries = Feed.read(coll.href, 'Entries post-update', reporter)
|
71
|
+
entries.each do |from_feed|
|
72
|
+
want = wanted.pop
|
73
|
+
unless from_feed.child_content('title').index(want)
|
74
|
+
reporter.error(self, "Entries feed out of order after update of multi-post.")
|
75
|
+
return
|
76
|
+
end
|
77
|
+
|
78
|
+
# next to godliness
|
79
|
+
delete_entry(from_feed)
|
80
|
+
|
81
|
+
break if wanted.empty?
|
82
|
+
end
|
83
|
+
reporter.success(self, "Entries correctly ordered after update of multi-post.") if correctly_ordered
|
84
|
+
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/lib/ape/version.rb
CHANGED
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
1
|
<?xml version="1.0" ?>
|
2
2
|
<entry xmlns="http://www.w3.org/2005/Atom">
|
3
3
|
<id><%= id %></id>
|
4
|
-
<title><%= title %></title>
|
4
|
+
<title><%= @title %></title>
|
5
5
|
<author><name>The Atom Protocol Exerciser</name></author>
|
6
6
|
<updated><%= now %></updated>
|
7
7
|
<link href='http://www.tbray.org/ape'/>
|
8
|
-
<summary type='html'><%= summary %></summary>
|
8
|
+
<summary type='html'><%= @summary %></summary>
|
9
9
|
<content type='xhtml'><div xmlns='http://www.w3.org/1999/xhtml'>
|
10
|
-
<p>A test post from the <APE> at
|
10
|
+
<p>A test post from the <APE> at <%= now %></p>
|
11
11
|
<p>If you see this in an entry, it's probably a left-over from an
|
12
12
|
unsuccessful Ape run; feel free to delete it.</p>
|
13
13
|
</div>
|
14
14
|
</content>
|
15
|
-
<dc:subject xmlns:dc='<%=subject%>'>Simians</dc:subject>
|
15
|
+
<dc:subject xmlns:dc='<%= @subject%>'>Simians</dc:subject>
|
16
16
|
</entry>
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
data/test/test_helper.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
$:.unshift File.dirname(__FILE__) + '/../lib'
|
2
2
|
require 'test/unit'
|
3
3
|
require 'ape'
|
4
|
+
require 'mocha'
|
4
5
|
|
5
6
|
def load_test_dir(dir)
|
6
7
|
Dir[File.join(File.dirname(__FILE__), dir, "*.rb")].each do |file|
|
@@ -13,5 +14,36 @@ module Writer
|
|
13
14
|
@response = response
|
14
15
|
end
|
15
16
|
end
|
16
|
-
|
17
17
|
Ape::Invoker.send(:include, Writer)
|
18
|
+
|
19
|
+
module ApeAccessors
|
20
|
+
def service=(service)
|
21
|
+
@service = service
|
22
|
+
end
|
23
|
+
|
24
|
+
def entry_collections=(colls)
|
25
|
+
@entry_collections = colls
|
26
|
+
end
|
27
|
+
|
28
|
+
def media_collections=(colls)
|
29
|
+
@media_collections = colls
|
30
|
+
end
|
31
|
+
|
32
|
+
def service
|
33
|
+
@service
|
34
|
+
end
|
35
|
+
|
36
|
+
def entry_collections
|
37
|
+
@entry_collections
|
38
|
+
end
|
39
|
+
|
40
|
+
def media_collections
|
41
|
+
@media_collections
|
42
|
+
end
|
43
|
+
end
|
44
|
+
Ape::Ape.send(:include, ApeAccessors)
|
45
|
+
|
46
|
+
class ValidatorMock < Ape::Validator
|
47
|
+
deterministic
|
48
|
+
requires_presence_of :entry_collection
|
49
|
+
end
|