ape 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/CHANGELOG +1 -0
  2. data/LICENSE +19 -0
  3. data/Manifest +45 -0
  4. data/README +12 -0
  5. data/ape.gemspec +57 -0
  6. data/bin/ape_server +28 -0
  7. data/lib/ape.rb +982 -0
  8. data/lib/ape/atomURI.rb +73 -0
  9. data/lib/ape/auth/google_login_credentials.rb +96 -0
  10. data/lib/ape/auth/wsse_credentials.rb +25 -0
  11. data/lib/ape/authent.rb +42 -0
  12. data/lib/ape/categories.rb +95 -0
  13. data/lib/ape/collection.rb +51 -0
  14. data/lib/ape/crumbs.rb +39 -0
  15. data/lib/ape/entry.rb +151 -0
  16. data/lib/ape/escaper.rb +29 -0
  17. data/lib/ape/feed.rb +117 -0
  18. data/lib/ape/handler.rb +34 -0
  19. data/lib/ape/html.rb +17 -0
  20. data/lib/ape/invoker.rb +54 -0
  21. data/lib/ape/invokers/deleter.rb +31 -0
  22. data/lib/ape/invokers/getter.rb +80 -0
  23. data/lib/ape/invokers/poster.rb +57 -0
  24. data/lib/ape/invokers/putter.rb +46 -0
  25. data/lib/ape/layout/ape.css +56 -0
  26. data/lib/ape/layout/ape_logo.png +0 -0
  27. data/lib/ape/layout/index.html +54 -0
  28. data/lib/ape/layout/info.png +0 -0
  29. data/lib/ape/names.rb +24 -0
  30. data/lib/ape/print_writer.rb +21 -0
  31. data/lib/ape/samples.rb +180 -0
  32. data/lib/ape/samples/atom_schema.txt +338 -0
  33. data/lib/ape/samples/basic_entry.eruby +16 -0
  34. data/lib/ape/samples/categories_schema.txt +69 -0
  35. data/lib/ape/samples/mini_entry.eruby +8 -0
  36. data/lib/ape/samples/service_schema.txt +187 -0
  37. data/lib/ape/samples/unclean_xhtml_entry.eruby +21 -0
  38. data/lib/ape/server.rb +32 -0
  39. data/lib/ape/service.rb +12 -0
  40. data/lib/ape/validator.rb +65 -0
  41. data/lib/ape/version.rb +9 -0
  42. data/scripts/go.rb +29 -0
  43. data/test/test_helper.rb +17 -0
  44. data/test/unit/authent_test.rb +35 -0
  45. data/test/unit/invoker_test.rb +25 -0
  46. data/test/unit/samples_test.rb +36 -0
  47. metadata +111 -0
@@ -0,0 +1 @@
1
+ v1. first gem version
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright © 2006 Sun Microsystems, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
17
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
18
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
19
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,45 @@
1
+ bin/ape_server
2
+ CHANGELOG
3
+ lib/ape/atomURI.rb
4
+ lib/ape/auth/google_login_credentials.rb
5
+ lib/ape/auth/wsse_credentials.rb
6
+ lib/ape/authent.rb
7
+ lib/ape/categories.rb
8
+ lib/ape/collection.rb
9
+ lib/ape/crumbs.rb
10
+ lib/ape/entry.rb
11
+ lib/ape/escaper.rb
12
+ lib/ape/feed.rb
13
+ lib/ape/handler.rb
14
+ lib/ape/html.rb
15
+ lib/ape/invoker.rb
16
+ lib/ape/invokers/deleter.rb
17
+ lib/ape/invokers/getter.rb
18
+ lib/ape/invokers/poster.rb
19
+ lib/ape/invokers/putter.rb
20
+ lib/ape/layout/ape.css
21
+ lib/ape/layout/ape_logo.png
22
+ lib/ape/layout/index.html
23
+ lib/ape/layout/info.png
24
+ lib/ape/names.rb
25
+ lib/ape/print_writer.rb
26
+ lib/ape/samples/atom_schema.txt
27
+ lib/ape/samples/basic_entry.eruby
28
+ lib/ape/samples/categories_schema.txt
29
+ lib/ape/samples/mini_entry.eruby
30
+ lib/ape/samples/service_schema.txt
31
+ lib/ape/samples/unclean_xhtml_entry.eruby
32
+ lib/ape/samples.rb
33
+ lib/ape/server.rb
34
+ lib/ape/service.rb
35
+ lib/ape/validator.rb
36
+ lib/ape/version.rb
37
+ lib/ape.rb
38
+ LICENSE
39
+ README
40
+ scripts/go.rb
41
+ test/test_helper.rb
42
+ test/unit/authent_test.rb
43
+ test/unit/invoker_test.rb
44
+ test/unit/samples_test.rb
45
+ Manifest
data/README ADDED
@@ -0,0 +1,12 @@
1
+ APE stands for Atom Protocol Exerciser, and that’s what it does. It’s written in Ruby, and is
2
+ designed to run on the Web, with a nice HTML interface describing its interactions with the
3
+ APP server under test.
4
+
5
+ == Licence
6
+
7
+ Copyright © 2006 Sun Microsystems, Inc. All rights reserved
8
+ Use is subject to license terms - see file "LICENSE"
9
+
10
+ == Install
11
+
12
+ sudo gem install ape
@@ -0,0 +1,57 @@
1
+
2
+ # Gem::Specification for Ape-1.0.0
3
+ # Originally generated by Echoe
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = %q{ape}
7
+ s.version = "1.0.0"
8
+
9
+ s.specification_version = 2 if s.respond_to? :specification_version=
10
+
11
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
+ s.authors = ["Tim Bray"]
13
+ s.date = %q{2008-02-22}
14
+ s.default_executable = %q{ape_server}
15
+ s.description = %q{A tool to exercice AtomPub server.}
16
+ s.email = %q{tim.bray@sun.com}
17
+ s.executables = ["ape_server"]
18
+ s.files = ["bin/ape_server", "CHANGELOG", "lib/ape/atomURI.rb", "lib/ape/auth/google_login_credentials.rb", "lib/ape/auth/wsse_credentials.rb", "lib/ape/authent.rb", "lib/ape/categories.rb", "lib/ape/collection.rb", "lib/ape/crumbs.rb", "lib/ape/entry.rb", "lib/ape/escaper.rb", "lib/ape/feed.rb", "lib/ape/handler.rb", "lib/ape/html.rb", "lib/ape/invoker.rb", "lib/ape/invokers/deleter.rb", "lib/ape/invokers/getter.rb", "lib/ape/invokers/poster.rb", "lib/ape/invokers/putter.rb", "lib/ape/layout/ape.css", "lib/ape/layout/ape_logo.png", "lib/ape/layout/index.html", "lib/ape/layout/info.png", "lib/ape/names.rb", "lib/ape/print_writer.rb", "lib/ape/samples/atom_schema.txt", "lib/ape/samples/basic_entry.eruby", "lib/ape/samples/categories_schema.txt", "lib/ape/samples/mini_entry.eruby", "lib/ape/samples/service_schema.txt", "lib/ape/samples/unclean_xhtml_entry.eruby", "lib/ape/samples.rb", "lib/ape/server.rb", "lib/ape/service.rb", "lib/ape/validator.rb", "lib/ape/version.rb", "lib/ape.rb", "LICENSE", "README", "scripts/go.rb", "test/test_helper.rb", "test/unit/authent_test.rb", "test/unit/invoker_test.rb", "test/unit/samples_test.rb", "Manifest", "ape.gemspec"]
19
+ s.has_rdoc = true
20
+ s.homepage = %q{http://www.tbray.org/ongoing/misc/Software#p-4}
21
+ s.require_paths = ["lib"]
22
+ s.rubyforge_project = %q{ape}
23
+ s.rubygems_version = %q{1.0.0}
24
+ s.summary = %q{A tool to exercice AtomPub server.}
25
+ s.test_files = ["test/unit/authent_test.rb", "test/unit/invoker_test.rb", "test/unit/samples_test.rb"]
26
+
27
+ s.add_dependency(%q<builder>, [">= 0", "= 2.1.2"])
28
+ end
29
+
30
+
31
+ # # Original Rakefile source (requires the Echoe gem):
32
+ #
33
+ # require File.dirname(__FILE__) + '/lib/ape/version'
34
+ #
35
+ # begin
36
+ # require 'rubygems'
37
+ # require 'echoe'
38
+ # Echoe.new('ape', Ape::VERSION::STRING) do |p|
39
+ # p.rubyforge_name = 'ape'
40
+ # p.summary = 'A tool to exercice AtomPub server.'
41
+ # p.url = 'http://www.tbray.org/ongoing/misc/Software#p-4'
42
+ # p.author = 'Tim Bray'
43
+ # p.email = 'tim.bray@sun.com'
44
+ # p.dependencies << 'builder >= 2.1.2'
45
+ # p.extra_deps = ['mongrel >= 1.1.3', 'erubis >= 2.5.0']
46
+ # p.test_pattern = 'test/unit/*.rb'
47
+ # end
48
+ # rescue LoadError => boom
49
+ # puts 'You are missing a dependency required for meta-operations on this gem.'
50
+ # puts boom.to_s.capitalize
51
+ # end
52
+ #
53
+ # desc 'Install the package as a gem, without generating documentation(ri/rdoc)'
54
+ # task :install_gem_no_doc => [:clean, :package] do
55
+ # sh "#{'sudo ' unless Hoe::WINDOZE }gem install pkg/*.gem --no-rdoc --no-ri"
56
+ # end
57
+ #
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
3
+ # Use is subject to license terms - see file "LICENSE"
4
+ $:.unshift File.dirname(__FILE__) + '/../lib'
5
+
6
+ require 'rubygems'
7
+ require 'mongrel'
8
+ require 'optparse'
9
+ require 'ape/server'
10
+ require 'ape/samples'
11
+
12
+ OPTIONS = {
13
+ :host => '0.0.0.0',
14
+ :port => '4000',
15
+ :home => nil
16
+ }
17
+
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = '@@ FIXME'
20
+ opts.separator ''
21
+ opts.on('-a', '--address ADDRESS', 'Address to bind to', "default: #{OPTIONS[:host]}") { |v| OPTIONS[:host] = v }
22
+ opts.on('-p', '--port PORT', 'Port to bind to', "default: #{OPTIONS[:port]}") { |v| OPTIONS[:port] = v }
23
+ opts.on('-d', '--directory DIRECTORY', 'ape home directory', "default: #{Ape::Samples.home}") { |v| OPTIONS[:home] = v }
24
+ opts.on('-h', '--help', 'Displays this help') { puts opts; exit }
25
+ opts.parse!(ARGV)
26
+ end
27
+
28
+ Ape::Server.run(OPTIONS)
@@ -0,0 +1,982 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+ $:.unshift File.dirname(__FILE__)
4
+
5
+ require 'rubygems'
6
+ require 'rexml/document'
7
+ require 'builder'
8
+
9
+ Dir[File.dirname(__FILE__) + '/ape/*.rb'].each { |l| require l }
10
+
11
+ module Ape
12
+ class Ape
13
+ def initialize(args)
14
+ @dialogs = (args[:crumbs]) ? {} : []
15
+ output = args[:output] || 'html'
16
+ if output == 'text' || output == 'html'
17
+ @output = output
18
+ else
19
+ raise ArgumentError, "output must be 'text' or 'html'"
20
+ end
21
+
22
+ @diarefs = {}
23
+ @dianum = 1
24
+ @@debugging = args[:debug]
25
+ @steps = []
26
+ @header = @footer = nil
27
+ @lnum = 1
28
+ @errors = @warnings = 0
29
+ end
30
+
31
+ # Args: APP URI, username/password, preferred entry/media collections
32
+ def check(uri, username=nil, password=nil,
33
+ requested_e_coll = nil, requested_m_coll = nil)
34
+
35
+ # Google athent weirdness
36
+ @authent = Authent.new(username, password)
37
+ header(uri)
38
+ begin
39
+ might_fail(uri, requested_e_coll, requested_m_coll)
40
+ rescue Exception
41
+ error "Ouch! Ape fall down go boom; details: " +
42
+ "#{$!}\n#{$!.class}\n#{$!.backtrace}"
43
+ puts $!.backtrace.join("\n")
44
+ end
45
+ end
46
+
47
+ def might_fail(uri, requested_e_coll = nil, requested_m_coll = nil)
48
+
49
+ info "TESTING: Service document and collections."
50
+ name = 'Retrieval of Service Document'
51
+ service = check_resource(uri, name, Names::AppMediaType)
52
+ return unless service
53
+
54
+ # * XML-parse the service doc
55
+ text = service.body
56
+ begin
57
+ service = REXML::Document.new(text, { :raw => nil })
58
+ rescue REXML::ParseException
59
+ prob = $!.to_s.gsub(/\n/, '<br/>')
60
+ error "Service document not well-formed: #{prob}"
61
+ return
62
+ end
63
+
64
+ # RNC-validate the service doc
65
+ Validator.validate(Samples.service_RNC, text, 'Service doc', self)
66
+
67
+ # * Do we have collections we can post an entry and a picture to?
68
+ # the requested_* arguments are the requested collection titles; if
69
+ # provided, try to match them, otherwise just pick the first listed
70
+ #
71
+ begin
72
+ collections = Service.collections(service, uri)
73
+ rescue Exception
74
+ error "Couldn't read collections from service doc: #{$!}"
75
+ return
76
+ end
77
+ entry_coll = media_coll = nil
78
+ if collections.length > 0
79
+ start_list "Found these collections"
80
+ collections.each do |collection|
81
+ list_item "'#{collection.title}' " +
82
+ "accepts #{collection.accept.join(', ')}"
83
+ if (!entry_coll) && collection.accept.index(Names::AtomEntryMediaType)
84
+ if requested_e_coll
85
+ if requested_e_coll == collection.title
86
+ entry_coll = collection
87
+ end
88
+ else
89
+ entry_coll = collection
90
+ end
91
+ end
92
+
93
+ if !media_coll
94
+ image_jpeg_ok = false
95
+ collection.accept.each do |types|
96
+ types.split(/, */).each do |type|
97
+
98
+ if type == '*/*' || type == 'image/*' || type == 'image/jpeg'
99
+ image_jpeg_ok = true
100
+ end
101
+ end
102
+ end
103
+ if image_jpeg_ok
104
+ if requested_m_coll
105
+ if requested_m_coll == collection.title
106
+ media_coll = collection
107
+ end
108
+ else
109
+ media_coll = collection
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ end_list
117
+
118
+ if entry_coll
119
+ info "Will use collection '#{entry_coll.title}' for entry creation."
120
+ test_entry_posts entry_coll
121
+ test_sorting entry_coll
122
+ test_sanitization entry_coll
123
+ else
124
+ warning "No collection for 'application/atom+xml;type=entry', won't test entry posting."
125
+ end
126
+
127
+ if media_coll
128
+ info "Will use collection '#{media_coll.title}' for media creation."
129
+ test_media_posts media_coll.href
130
+ test_media_linkage media_coll
131
+ else
132
+ warning "No collection for 'image/jpeg', won't test media posting."
133
+ end
134
+ end
135
+
136
+ def test_media_linkage(coll)
137
+ info "TESTING: Media collection re-ordering after PUT."
138
+
139
+ # We'll post three mini entries to the collection
140
+ data = Samples.picture
141
+ poster = Poster.new(coll.href, @authent)
142
+ ['One', 'Two', 'Three'].each do |num|
143
+ slug = "Picture #{num}"
144
+ poster.set_header('Slug', slug)
145
+ name = "Posting pic #{num}"
146
+ worked = poster.post('image/jpeg', data)
147
+ save_dialog(name, poster)
148
+ if !worked
149
+ error("Can't POST Picture #{num}: #{poster.last_error}", name)
150
+ return
151
+ end
152
+ sleep 2
153
+ end
154
+
155
+ # grab the collection to gather the MLE ids
156
+ entries = Feed.read(coll.href, 'Pictures from multi-post', self, true)
157
+ if entries.size < 3
158
+ error "Pictures apparently not in collection"
159
+ return
160
+ end
161
+
162
+ ids = entries.map { |e| e.child_content('id)') }
163
+
164
+ # let's update one of them; have to fetch it first to get the ETag
165
+ two_media = entries[1].link('edit-media')
166
+ if !two_media
167
+ error "Second entry from feed doesn't have an 'edit-media' link."
168
+ return
169
+ end
170
+ two_resp = check_resource(two_media, 'Fetch image to get ETag', 'image/jpeg', true)
171
+ unless two_resp
172
+ error "Can't fetch image to get ETag"
173
+ return
174
+ end
175
+ etag = two_resp.header 'etag'
176
+
177
+ putter = Putter.new(two_media, @authent)
178
+ putter.set_header('If-Match', etag)
179
+
180
+ name = 'Updating one of three pix with PUT'
181
+ if putter.put('image/jpeg', data)
182
+ good "Update one of newly posted pictures went OK."
183
+ else
184
+ save_dialog(name, putter)
185
+ error("Can't update picture at #{two_media}", name)
186
+ return
187
+ end
188
+
189
+ # now the order should have changed
190
+ wanted = [ ids[2], ids[0], ids[1] ]
191
+ entries = Feed.read(coll.href, 'MLEs post-update', self, true)
192
+ entries.each do |from_feed|
193
+ want = wanted.pop
194
+ unless from_feed.child_content('id').eql?(want)
195
+ error "Updating bits failed to re-order link entries in media collection."
196
+ return
197
+ end
198
+
199
+ # next to godliness
200
+ delete_entry(from_feed)
201
+
202
+ break if wanted.empty?
203
+ end
204
+ good "Entries correctly ordered after update of multi-post."
205
+
206
+ end
207
+
208
+ def test_sanitization(coll)
209
+ info "TESTING: Content sanitization"
210
+
211
+ poster = Poster.new(coll.href, @authent)
212
+ name = 'Posting unclean XHTML'
213
+ worked = poster.post(Names::AtomEntryMediaType, Samples.unclean_xhtml_entry)
214
+ if !worked
215
+ save_dialog(name, poster)
216
+ error("Can't POST unclean XHTML: #{poster.last_error}", name)
217
+ return
218
+ end
219
+
220
+ location = poster.header('Location')
221
+ name = "Retrieval of unclean XHTML entry"
222
+ entry = check_resource(location, name, Names::AtomMediaType)
223
+ return unless entry
224
+
225
+ begin
226
+ entry = Entry.new(entry.body, location)
227
+ rescue REXML::ParseException
228
+ prob = $!.to_s.gsub(/\n/, '<br/>')
229
+ error "New entry is not well-formed: #{prob}"
230
+ return
231
+ end
232
+
233
+ no_problem = true
234
+ patterns = {
235
+ '//xhtml:script' => "Published entry retains xhtml:script element.",
236
+ '//*[@background]' => "Published entry retains 'background' attribute.",
237
+ '//*[@style]' => "Published entry retains 'style' attribute.",
238
+
239
+ }
240
+ patterns.each { |xp, message| warning(message) unless entry.xpath_match(xp).empty? }
241
+
242
+ entry.xpath_match('//xhtml:a').each do |a|
243
+ if a.attributes['href'] =~ /^([a-zA-Z]+):/
244
+ if $1 != 'http'
245
+ no_problem = false
246
+ warning "Published entry retains dangerous hyperlink: '#{a.attributes['href']}'."
247
+ end
248
+ end
249
+ end
250
+
251
+ delete_entry(entry)
252
+
253
+ good "Published entry appears to be sanitized." if no_problem
254
+ end
255
+
256
+ def test_sorting(coll)
257
+
258
+ info "TESTING: Collection re-ordering after PUT."
259
+
260
+ # We'll post three mini entries to the collection
261
+ poster = Poster.new(coll.href, @authent)
262
+ ['One', 'Two', 'Three'].each do |num|
263
+ sleep 2
264
+ text = Samples.mini_entry.gsub('Mini-1', "Mini #{num}")
265
+ name = "Posting Mini #{num}"
266
+ worked = poster.post(Names::AtomEntryMediaType, text)
267
+ save_dialog(name, poster)
268
+ if !worked
269
+ error("Can't POST Mini #{name}: #{poster.last_error}", name)
270
+ return
271
+ end
272
+ end
273
+
274
+ # now let's grab the collection & check the order
275
+ wanted = ['Mini One', 'Mini Two', 'Mini Three']
276
+ two = nil
277
+ entries = Feed.read(coll.href, 'Entries with multi-post', self, true)
278
+ entries.each do |from_feed|
279
+ want = wanted.pop
280
+ unless from_feed.child_content('title').index(want)
281
+ error "Entries feed out of order after multi-post."
282
+ return
283
+ end
284
+ two = from_feed if want == 'Mini Two'
285
+ break if wanted.empty?
286
+ end
287
+ good "Entries correctly ordered after multi-post."
288
+
289
+ # let's update one of them; have to fetch it first to get the ETag
290
+ link = two.link('edit', self)
291
+ unless link
292
+ error "Can't check entry without edit link, entry id: #{two.get_child('id/text()')}"
293
+ return
294
+ end
295
+ two_resp = check_resource(link, 'fetch two', Names::AtomMediaType, false)
296
+
297
+ correctly_ordered = false
298
+ if two_resp
299
+ etag = two_resp.header 'etag'
300
+
301
+ putter = Putter.new(link, @authent)
302
+ putter.set_header('If-Match', etag)
303
+
304
+ name = 'Updating mini-entry with PUT'
305
+ sleep 2
306
+ updated = two_resp.body.gsub('Mini Two', 'Mini-4')
307
+ unless putter.put(Names::AtomEntryMediaType, updated)
308
+ save_dialog(name, putter)
309
+ error("Can't update mini-entry at #{link}", name)
310
+ return
311
+ end
312
+ # now the order should have changed
313
+ wanted = ['Mini One', 'Mini Three', 'Mini-4']
314
+ correctly_ordered = true
315
+ else
316
+ error "Mini Two entry not received. Can't assure the correct order after update."
317
+ wanted = ['Mini One', 'Mini Two', 'Mini Three']
318
+ end
319
+
320
+ entries = Feed.read(coll.href, 'Entries post-update', self, true)
321
+ entries.each do |from_feed|
322
+ want = wanted.pop
323
+ unless from_feed.child_content('title').index(want)
324
+ error "Entries feed out of order after update of multi-post."
325
+ return
326
+ end
327
+
328
+ # next to godliness
329
+ delete_entry(from_feed)
330
+
331
+ break if wanted.empty?
332
+ end
333
+ good "Entries correctly ordered after update of multi-post." if correctly_ordered
334
+
335
+ end
336
+
337
+ def test_entry_posts(entry_collection)
338
+
339
+ collection_uri = entry_collection.href
340
+ entries = Feed.read(collection_uri, 'Entry collection', self)
341
+
342
+ # * List the current entries, remember which IDs we've seen
343
+ info "TESTING: Entry-posting basics."
344
+ ids = []
345
+ unless entries.empty?
346
+ start_list "Now in the Entries feed"
347
+ entries.each do |entry|
348
+ list_item entry.summarize
349
+ ids << entry.child_content('id')
350
+ end
351
+ end_list
352
+ end
353
+
354
+ # Setting up to post a new entry
355
+ poster = Poster.new(collection_uri, @authent)
356
+ if poster.last_error
357
+ error("Unacceptable URI for '#{entry_collection.title}' collection: " +
358
+ poster.last_error)
359
+ return
360
+ end
361
+
362
+ my_entry = Entry.new(Samples.basic_entry)
363
+
364
+ # ask it to use this in the URI
365
+ slug_num = rand(100000)
366
+ slug = "ape-#{slug_num}"
367
+ slug_re = %r{ape.?#{slug_num}}
368
+ poster.set_header('Slug', slug)
369
+
370
+ # add some categories to the entry, and remember which
371
+ @cats = Categories.add_cats(my_entry, entry_collection, @authent, self)
372
+
373
+ # * OK, post it
374
+ worked = poster.post(Names::AtomEntryMediaType, my_entry.to_s)
375
+ name = 'Posting new entry'
376
+ save_dialog(name, poster)
377
+ if !worked
378
+ error("Can't POST new entry: #{poster.last_error}", name)
379
+ return
380
+ end
381
+
382
+ location = poster.header('Location')
383
+ unless location
384
+ error("No Location header upon POST creation", name)
385
+ return
386
+ end
387
+ good("Posting of new entry to the Entries collection " +
388
+ "reported success, Location: #{location}", name)
389
+
390
+ info "Examining the new entry as returned in the POST response"
391
+ check_new_entry(my_entry, poster.entry, "Returned entry") if poster.entry
392
+
393
+ # * See if the Location uri can be retrieved, and check its consistency
394
+ name = "Retrieval of newly created entry"
395
+ new_entry = check_resource(location, name, Names::AtomMediaType)
396
+ return unless new_entry
397
+
398
+ # Grab its etag
399
+ etag = new_entry.header 'etag'
400
+
401
+ info "Examining the new entry as retrieved using Location header in POST response:"
402
+
403
+ begin
404
+ new_entry = Entry.new(new_entry.body, location)
405
+ rescue REXML::ParseException
406
+ prob = $!.to_s.gsub(/\n/, '<br/>')
407
+ error "New entry is not well-formed: #{prob}"
408
+ return
409
+ end
410
+
411
+ # * See if the slug was used
412
+ slug_used = false
413
+ new_entry.alt_links.each do |a|
414
+ href = a.attributes['href']
415
+ if href && href.index(slug_re)
416
+ slug_used = true
417
+ end
418
+ end
419
+ if slug_used
420
+ good "Client-provided slug '#{slug}' was used in server-generated URI."
421
+ else
422
+ warning "Client-provided slug '#{slug}' not used in server-generated URI."
423
+ end
424
+
425
+ check_new_entry(my_entry, new_entry, "Retrieved entry")
426
+
427
+ entry_id = new_entry.child_content('id')
428
+
429
+ # * fetch the feed again and check that version
430
+ from_feed = find_entry(collection_uri, "entry collection", entry_id)
431
+ if from_feed.class == String
432
+ good "About to check #{collection_uri}"
433
+ Feed.read(collection_uri, "Can't find entry in collection", self)
434
+ error "New entry didn't show up in the collections feed."
435
+ return
436
+ end
437
+
438
+ info "Examining the new entry as it appears in the collection feed:"
439
+
440
+ # * Check the entry from the feed
441
+ check_new_entry(my_entry, from_feed, "Entry from collection feed")
442
+
443
+ edit_uri = new_entry.link('edit', self)
444
+ if !edit_uri
445
+ error "Entry from Location header has no edit link."
446
+ return
447
+ end
448
+
449
+ # * Update the entry, see if the update took
450
+ name = 'In-place update with put'
451
+ putter = Putter.new(edit_uri, @authent)
452
+
453
+ # Conditional PUT if an etag
454
+ putter.set_header('If-Match', etag) if etag
455
+
456
+ new_title = "Let’s all do the Ape!"
457
+ new_text = Samples.retitled_entry(new_title, entry_id)
458
+ response = putter.put(Names::AtomEntryMediaType, new_text)
459
+ save_dialog(name, putter)
460
+
461
+ if response
462
+ good("Update of new entry reported success.", name)
463
+ from_feed = find_entry(collection_uri, "entry collection", entry_id)
464
+ if from_feed.class == String
465
+ check_resource(collection_uri, "Check collection after lost update", nil, true)
466
+ error "Updated entry ID #{entry_id} not found in entries collection."
467
+ return
468
+ end
469
+ if from_feed.child_content('title') == new_title
470
+ good "Title of new entry successfully updated."
471
+ else
472
+ warning "After PUT update of title, Expected " +
473
+ "'#{new_title}', but saw '#{from_feed.child_content('title')}'"
474
+ end
475
+ else
476
+ warning("Can't update new entry with PUT: #{putter.last_error}", name)
477
+ end
478
+
479
+ # the edit-uri might have changed
480
+ return unless delete_entry(from_feed, 'New Entry deletion')
481
+
482
+ # See if it's gone from the feed
483
+ still_there = find_entry(collection_uri, "entry collection", entry_id)
484
+ if still_there.class != String
485
+ error "Entry is still in collection post-deletion."
486
+ else
487
+ good "Entry not found in feed after deletion."
488
+ end
489
+
490
+ end
491
+
492
+ def test_media_posts media_collection
493
+
494
+ info "TESTING: Posting to media collection."
495
+
496
+ # * Post a picture to the media collection
497
+ #
498
+ poster = Poster.new(media_collection, @authent)
499
+ if poster.last_error
500
+ error("Unacceptable URI for '#{media_coll.title}' collection: " +
501
+ poster.last_error)
502
+ return
503
+ end
504
+
505
+ name = 'Post image to media collection'
506
+
507
+ # ask it to use this in the URI
508
+ slug_num = rand(100000)
509
+ slug = "apix-#{slug_num}"
510
+ slug_re = %r{apix.?#{slug_num}}
511
+ poster.set_header('Slug', slug)
512
+
513
+ #poster.set_header('Slug', slug)
514
+ worked = poster.post('image/jpeg', Samples.picture)
515
+ save_dialog(name, poster)
516
+ if !worked
517
+ error("Can't POST picture to media collection: #{poster.last_error}",
518
+ name)
519
+ return
520
+ end
521
+
522
+ good("Post of image file reported success, media link location: " +
523
+ "#{poster.header('Location')}", name)
524
+
525
+ # * Retrieve the media link entry
526
+ mle_uri = poster.header('Location')
527
+
528
+ media_link_entry = check_resource(mle_uri, 'Retrieval of media link entry', Names::AtomMediaType)
529
+ return unless media_link_entry
530
+
531
+ if media_link_entry.last_error
532
+ error "Can't proceed with media-post testing."
533
+ return
534
+ end
535
+
536
+ # * See if the <content src= is there and usable
537
+ begin
538
+ media_link_entry = Entry.new(media_link_entry.body, mle_uri)
539
+ rescue REXML::ParseException
540
+ prob = $!.to_s.gsub(/\n/, '<br/>')
541
+ error "Media link entry is not well-formed: #{prob}"
542
+ return
543
+ end
544
+ content_src = media_link_entry.content_src
545
+ if (!content_src) || (content_src == "")
546
+ error "Media link entry has no content@src pointer to media resource."
547
+ return
548
+ end
549
+
550
+ # see if slug was used in media URI
551
+ if content_src =~ slug_re
552
+ good "Client-provided slug '#{slug}' was used in Media Resource URI."
553
+ else
554
+ warning "Client-provided slug '#{slug}' not used in Media Resource URI."
555
+ end
556
+
557
+ media_link_id = media_link_entry.child_content('id')
558
+
559
+ name = 'Retrieval of media resource'
560
+ picture = check_resource(content_src, name, 'image/jpeg')
561
+ return unless picture
562
+
563
+ if picture.body == Samples.picture
564
+ good "Media resource was apparently stored and retrieved properly."
565
+ else
566
+ warning "Media resource differs from posted picture"
567
+ end
568
+
569
+ # * Delete the media link entry
570
+ return unless delete_entry(media_link_entry, 'Deletion of media link entry')
571
+
572
+ # * media link entry still in feed?
573
+ still_there = find_entry(media_collection, "media collection", media_link_id)
574
+ if still_there.class != String
575
+ error "Media link entry is still in collection post-deletion."
576
+ else
577
+ good "Media link entry no longer in feed."
578
+ end
579
+
580
+ # is the resource there any more?
581
+ name = 'Check Media Resource deletion'
582
+ if check_resource(content_src, name, 'image/jpeg', false)
583
+ error "Media resource still there after media link entry deletion."
584
+ else
585
+ good "Media resource no longer fetchable."
586
+ end
587
+
588
+ end
589
+
590
+ def check_new_entry(as_posted, new_entry, desc)
591
+
592
+ if compare_entries(as_posted, new_entry, "entry as posted", desc)
593
+ good "#{desc} is consistent with posted entry."
594
+ end
595
+
596
+ # * See if the categories we sent made it in
597
+ cat_probs = false
598
+ @cats.each do |cat|
599
+ if !new_entry.has_cat(cat)
600
+ cat_probs = true
601
+ warning "Provided category not in #{desc}: #{cat}"
602
+ end
603
+ end
604
+ good "Provided categories included in #{desc}." unless cat_probs
605
+
606
+ # * See if the dc:subject survived
607
+ dc_subject = new_entry.child_content(Samples.foreign_child, Samples.foreign_namespace)
608
+ if dc_subject
609
+ if dc_subject == Samples.foreign_child_content
610
+ good "Server preserved foreign markup in #{desc}."
611
+ else
612
+ warning "Server altered content of foreign markup in #{desc}."
613
+ end
614
+ else
615
+ warning "Server discarded foreign markup in #{desc}."
616
+ end
617
+ end
618
+
619
+ #
620
+ # End of tests; support functions from here down
621
+ #
622
+
623
+ # Fetch a feed and look up an entry by ID in it
624
+ def find_entry(feed_uri, name, id, report=false)
625
+ entries = Feed.read(feed_uri, name, self, report)
626
+ entries.each do |from_feed|
627
+ return from_feed if id == from_feed.child_content('id')
628
+ end
629
+
630
+ return "Couldn't find id #{id} in feed #{feed_uri}"
631
+ end
632
+
633
+ # remember the dialogue that the get/put/post/delete actor recorded
634
+ def save_dialog(name, actor)
635
+ @dialogs[name] = actor.crumbs if @dialogs
636
+ end
637
+
638
+ # Get a resource, optionally check its content-type
639
+ def check_resource(uri, name, content_type, report=true)
640
+ resource = Getter.new(uri, @authent)
641
+
642
+ # * Check the URI
643
+ if resource.last_error
644
+ error("Unacceptable #{name} URI: " + resource.last_error, name) if report
645
+ return nil
646
+ end
647
+
648
+ # * Get it, make sure it has the right content-type
649
+ worked = resource.get(content_type)
650
+ @dialogs[name] = resource.crumbs if @dialogs
651
+
652
+ if (resource.security_warning and not @security_warning)
653
+ @security_warning = true
654
+ warning("Sending authentication information over a open channel is not a good security practice.", name)
655
+ end
656
+
657
+ if !worked
658
+ # oops, couldn't even get get it
659
+ error("#{name} failed: " + resource.last_error, name) if report
660
+ return nil
661
+
662
+ elsif resource.last_error
663
+ # oops, media-type problem
664
+ error("#{name}: #{resource.last_error}", name) if report
665
+
666
+ else
667
+ # resource fetched and is of right type
668
+ good("#{name}: it exists and is served properly.", name) if report
669
+ end
670
+
671
+ return resource
672
+ end
673
+
674
+ def header(uri)
675
+ @header = "APP Service doc: #{uri}"
676
+ end
677
+
678
+ def footer(message)
679
+ @footer = message
680
+ end
681
+
682
+ def show_crumbs key
683
+ @dialogs[key].each do |d|
684
+ puts "D: #{d}"
685
+ end
686
+ end
687
+
688
+ def warning(message, crumb_key=nil)
689
+ @warnings += 1
690
+ if @dialogs
691
+ step "D#{crumb_key}" if crumb_key
692
+ show_crumbs(crumb_key) if crumb_key && @@debugging
693
+ end
694
+ step "W" + message
695
+ end
696
+
697
+ def error(message, crumb_key=nil)
698
+ @errors += 1
699
+ if @dialogs
700
+ step "D#{crumb_key}" if crumb_key
701
+ show_crumbs(crumb_key) if crumb_key && @@debugging
702
+ end
703
+ step "E" + message
704
+ end
705
+
706
+ def good(message, crumb_key=nil)
707
+ if @dialogs
708
+ step "D#{crumb_key}" if crumb_key
709
+ show_crumbs(crumb_key) if crumb_key && @@debugging
710
+ end
711
+ step "G" + message
712
+ end
713
+
714
+ def info(message)
715
+ step "I" + message
716
+ end
717
+
718
+ def step(message)
719
+ puts "PROGRESS: #{message[1..-1]}" if @@debugging
720
+ @steps << message
721
+ end
722
+
723
+ def start_list(message)
724
+ step [ message + ":" ]
725
+ end
726
+
727
+ def list_item(message)
728
+ @steps[-1] << message
729
+ end
730
+
731
+ def end_list
732
+ end
733
+
734
+ def line
735
+ printf "%2d. ", @lnum
736
+ @lnum += 1
737
+ end
738
+
739
+ def report(output=STDOUT)
740
+ if @output == 'text'
741
+ report_text output
742
+ else
743
+ report_html output
744
+ end
745
+ end
746
+
747
+ def report_html(output=STDOUT)
748
+ dialog = nil
749
+
750
+ if output == STDOUT
751
+ output.puts "Status: 200 OK\r"
752
+ output.puts "Content-type: text/html; charset=utf-8\r"
753
+ output.puts "\r"
754
+ end
755
+
756
+ @w = Builder::XmlMarkup.new(:target => output)
757
+ @w.html do
758
+ @w.head do
759
+ @w.title { @w.text! 'Atom Protocol Exerciser Report' }
760
+ @w.text! "\n"
761
+ @w.link(:rel => 'stylesheet', :type => 'text/css',:href => '../ape/ape.css' )
762
+ end
763
+ @w.text! "\n"
764
+ @w.body do
765
+ @w.h2 { @w.text! 'The Ape says:' }
766
+ @w.text! "\n"
767
+ if @header
768
+ @w.p { @w.text! @header }
769
+ @w.p do
770
+ @w.text! "Summary: "
771
+ @w.text!((@errors == 1) ? '1 error, ' : "#{@errors} errors, ")
772
+ @w.text!((@warnings == 1) ? '1 warning.' : "#{@warnings} warnings.")
773
+ end
774
+ @w.text! "\n"
775
+ end
776
+ @w.ol do
777
+ @w.text! "\n"
778
+ @steps.each do |step|
779
+ if step.kind_of? Array
780
+ # it's a list; no dialog applies
781
+ @w.li do
782
+ @w.p do
783
+ write_mark :info
784
+ @w.text! " #{step[0]}\n"
785
+ end
786
+ @w.ul do
787
+ step[1 .. -1].each { |li| report_li(nil, nil, li) }
788
+ end
789
+ @w.text! "\n"
790
+ end
791
+ else
792
+ body = step[1 .. -1]
793
+ opcode = step[0,1]
794
+ if opcode == "D"
795
+ dialog = body
796
+ else
797
+ case opcode
798
+ when "W" then report_li(dialog, :question, body)
799
+ when "E" then report_li(dialog, :exclamation, body)
800
+ when "G" then report_li(dialog, :check, body)
801
+ when "I" then report_li(dialog, :info, body)
802
+ else
803
+ line
804
+ puts "HUH? #{step}"
805
+ end
806
+ dialog = nil
807
+ end
808
+ end
809
+ end
810
+ end
811
+
812
+ @w.text! "\n"
813
+ if @footer then @w.p { @w.text! @footer } end
814
+ @w.text! "\n"
815
+
816
+ #unless @dialog.nil?
817
+ if @dialogs
818
+ @w.h2 { @w.text! 'Recorded client/server dialogs' }
819
+ @w.text! "\n"
820
+ @diarefs.each do |k, v|
821
+ dialog = @dialogs[k]
822
+ @w.h3(:id => "dia-#{v}") do
823
+ @w.text! k
824
+ end
825
+ @w.div(:class => 'dialog') do
826
+
827
+ @w.div(:class => 'dialab') do
828
+ @w.text! "\nTo server:\n"
829
+ dialog.grep(/^>/).each { |crumb| show_message(crumb, :to) }
830
+ end
831
+ @w.div( :class => 'dialab' ) do
832
+ @w.text! "\nFrom Server:\n"
833
+ dialog.grep(/^</).each { |crumb| show_message(crumb, :from) }
834
+ end
835
+ end
836
+ end
837
+ end
838
+ end
839
+ end
840
+ end
841
+
842
+ def report_li(dialog, marker, text)
843
+ @w.li do
844
+ @w.p do
845
+ if marker
846
+ write_mark marker
847
+ @w.text! ' '
848
+ end
849
+ # preserve line-breaks in output
850
+ lines = text.split("\n")
851
+ lines[0 .. -2].each do |line|
852
+ @w.text! line
853
+ @w.br
854
+ end
855
+ @w.text! lines[-1] if lines[-1]
856
+
857
+ if dialog
858
+ @w.a(:class => 'diaref', :href => "#dia-#{@dianum}") do
859
+ @w.text! ' [Dialog]'
860
+ end
861
+ @diarefs[dialog] = @dianum
862
+ @dianum += 1
863
+ end
864
+ end
865
+ end
866
+ @w.text! "\n"
867
+ end
868
+
869
+ def show_message(crumb, tf)
870
+ message = crumb[1 .. -1]
871
+ message.gsub!(/^\s*"/, '')
872
+ message.gsub!(/"\s*$/, '')
873
+ message.gsub!(/\\"/, '"')
874
+ message = Escaper.escape message
875
+ message.gsub!(/\\n/, "\n<br/>")
876
+ message.gsub!(/\\t/, '&#xa0;&#xa0;&#xa0;&#xa0;')
877
+ @w.div(:class => tf) { @w.target! << message }
878
+ end
879
+
880
+ def report_text(output=STDOUT)
881
+ output.puts @header if @header
882
+ @steps.each do |step|
883
+ if step.class == Crumbs
884
+ output.puts " Dialog:"
885
+ step.each { |crumb| output.puts " #{crumb}" }
886
+ else
887
+ body = step[1 .. -1]
888
+ case step[0,1]
889
+ when "W"
890
+ line
891
+ output.puts "WARNING: #{body}"
892
+ when "E"
893
+ line
894
+ output.puts "ERROR: #{body}"
895
+ when "G"
896
+ line
897
+ output.puts body
898
+ when "L"
899
+ line
900
+ output.puts body
901
+ when "e"
902
+ # no-op
903
+ when "I"
904
+ output.puts " #{body}"
905
+ when "D"
906
+ # later, dude
907
+ else
908
+ line
909
+ output.puts "HUH? #{body}"
910
+ end
911
+ end
912
+ output.puts @footer if @footer
913
+ end
914
+ end
915
+
916
+ def compare_entries(e1, e2, e1Name, e2Name)
917
+ problems = 0
918
+ [ 'title', 'summary', 'content' ].each do |field|
919
+ problems += 1 if compare1(e1, e2, e1Name, e2Name, field)
920
+ end
921
+ return problems == 0
922
+ end
923
+
924
+ def compare1(e1, e2, e1Name, e2Name, field)
925
+ c1 = e1.child_content(field)
926
+ c2 = e2.child_content(field)
927
+ if c1 != c2
928
+ problem = true
929
+ if c1 == nil
930
+ warning "'#{field}' absent in #{e1Name}."
931
+ elsif c2 == nil
932
+ warning "'#{field}' absent in #{e2Name}."
933
+ else
934
+ t1 = e1.child_type(field)
935
+ t2 = e2.child_type(field)
936
+ if t1 != t2
937
+ warning "'#{field}' has type='#{t1}' " +
938
+ "in #{e1Name}, type='#{t2}' in #{e2Name}."
939
+ else
940
+ c1 = Escaper.escape(c1)
941
+ c2 = Escaper.escape(c2)
942
+ warning "'#{field}' in #{e1Name} [#{c1}] " +
943
+ "differs from that in #{e2Name} [#{c2}]."
944
+ end
945
+ end
946
+ end
947
+ return problem
948
+ end
949
+
950
+ def write_mark(mark)
951
+ case mark
952
+ when :check
953
+ @w.span(:class => 'good') { @w.target << '&#x2713;' }
954
+ when :question
955
+ @w.span(:class => 'warning') { @w.text! '?' }
956
+ when :exclamation
957
+ @w.span(:class => 'error') { @w.text! '!' }
958
+ when :info
959
+ @w.img(:align => 'top', :src => '../ape/info.png')
960
+ end
961
+ end
962
+
963
+ def delete_entry(entry, name = nil)
964
+ link = entry.link('edit', self)
965
+ unless link
966
+ error "Can't delete entry without edit link"
967
+ return false
968
+ end
969
+ deleter = Deleter.new(link, @authent)
970
+ worked = deleter.delete
971
+
972
+ save_dialog(name, deleter) if name
973
+ if worked
974
+ good("Entry deletion reported success.", name)
975
+ else
976
+ error("Couldn't delete the entry: " + deleter.last_error, name)
977
+ end
978
+ return worked
979
+ end
980
+ end
981
+ end
982
+