turn Coquelicot into a gem
[coquelicot.git] / spec / coquelicot_spec.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright ¬© 2010-2012 potager.org <jardiniers@potager.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 require 'spec_helper'
18
19 require 'timecop'
20 require 'hpricot'
21 require 'tmpdir'
22 require 'active_support'
23
24 UPLOAD_PASSWORD = 'secret'
25
26 # The specs in this file are written like what should have been Cucumber
27 # features and without much knowledge of best practices with RSpec. Most of
28 # them should be improved, rewritten and moved to `spec/coquelicot/app_spec.rb`.
29 #
30 # Once down, we could remove the dependency on Hpricot for the much better
31 # Capybara (which is used in `spec/coquelicot/app_spec.rb`).
32
33 describe 'Coquelicot' do
34   include Rack::Test::Methods
35
36   include_context 'with Coquelicot::Application'
37
38   def upload(opts={})
39     # We need the request to be in the right order
40     params = ActiveSupport::OrderedHash.new
41     params[:upload_password] = UPLOAD_PASSWORD
42     params[:expire] = 5
43     params[:one_time] = ''
44     params[:file_key] = ''
45     params[:file] = Rack::Test::UploadedFile.new(__FILE__, 'text/x-script.ruby')
46     params.merge!(opts)
47     data = build_multipart(params)
48     post '/upload', {}, { :input           => data,
49                           'CONTENT_LENGTH' => data.length.to_s,
50                           'CONTENT_TYPE'   => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}"
51                         }
52     return nil unless last_response.redirect?
53     follow_redirect!
54     last_response.should be_ok
55     doc = Hpricot(last_response.body)
56     return (doc/'a').collect { |a| a.attributes['href'] }.
57              select { |h| h.start_with? "http://#{last_request.host}/" }[0]
58   end
59
60   def build_multipart(params)
61     params.map do |name, value|
62       if value.is_a? Rack::Test::UploadedFile
63         <<-PART
64 --#{Rack::Multipart::MULTIPART_BOUNDARY}\r
65 Content-Disposition: form-data; name="#{name}"; filename="#{Rack::Utils.escape(value.original_filename)}"\r
66 Content-Type: #{value.content_type}\r
67 Content-Length: #{::File.stat(value.path).size}\r
68 \r
69 #{File.open(value.path).read}\r
70 PART
71       else
72         <<-PART
73 --#{Rack::Multipart::MULTIPART_BOUNDARY}\r
74 Content-Disposition: form-data; name="#{name}"\r
75 \r
76 #{value}\r
77 PART
78       end
79     end.join + "--#{Rack::Multipart::MULTIPART_BOUNDARY}--\r"
80   end
81
82   it "should offer an upload form" do
83     get '/'
84     last_response.should be_ok
85     doc = Hpricot(last_response.body)
86     (doc/"form#upload").should have(1).items
87   end
88
89   context "when I explicitely ask for french" do
90     it "should offer an upload form in french" do
91       get '/', :lang => 'fr'
92       last_response.should be_ok
93       doc = Hpricot(last_response.body)
94       (doc/"input.submit").attr('value').should == 'Partager¬†!'
95     end
96   end
97
98   context "when using 'simpleauth' authentication mechanism" do
99     before(:each) do
100       app.set :authentication_method, :name => :simplepass,
101                                       :upload_password => Digest::SHA1.hexdigest(UPLOAD_PASSWORD)
102     end
103
104     context "after a successful upload" do
105       before(:each) do
106         @url = upload
107       end
108
109       it "should not store the file in cleartext" do
110         files = Dir.glob("#{Coquelicot.depot.path}/*")
111         files.should have(2).items
112         File.new(files[0]).read().should_not include('should not store an uploaded file')
113       end
114
115       it "should generate a random URL to download the file" do
116         @url.should_not include(File.basename(__FILE__))
117       end
118
119       it "should store the file with a different name than the one in URL" do
120         url_name = @url.split('/')[-1]
121         files = Dir.glob("#{Coquelicot.depot.path}/*")
122         files.should have(2).items
123         url_name.should_not eql(File.basename(files[0]))
124       end
125
126       it "should encode the encryption key in URL as no password has been specified" do
127         url_name = @url.split('/')[-1]
128         url_name.split('-').should have(2).items
129       end
130
131       it "should say 'not found' is password in URL is wrong" do
132         get "#{@url}wrong"
133         last_response.status.should == 404
134       end
135
136       it "should download when using extra Base32 characters in URL" do
137         splitted = @url.split('/')
138         name = splitted[-1].upcase.gsub(/O/, '0').gsub(/L/, '1')
139         get "#{splitted[0..-2].join '/'}/#{name}"
140         last_response.should be_ok
141         last_response['Content-Type'].should eql('text/x-script.ruby')
142       end
143
144       context "when the file has been downloaded" do
145         before(:each) do
146           get @url
147         end
148
149         it "should be the same file as the uploaded" do
150           last_response.should be_ok
151           last_response['Content-Type'].should eql('text/x-script.ruby')
152           last_response.body.should eql(File.new(__FILE__).read)
153         end
154
155         it "should have sent the right Content-Length" do
156           last_response.should be_ok
157           last_response['Content-Length'].to_i.should == File.stat(__FILE__).size
158         end
159
160         it "should always has the same Last-Modified header" do
161           last_modified = last_response['Last-Modified']
162           last_modified.should_not be_nil
163           get @url
164           last_response['Last-Modified'].should eql(last_modified)
165         end
166       end
167     end
168
169     context "given an empty file" do
170       before do
171         @empty_file = Tempfile.new('empty')
172       end
173       it "should not be accepted when uploaded" do
174         url = upload :file => Rack::Test::UploadedFile.new(@empty_file.path, 'text/plain')
175         url.should be_nil
176         last_response.should_not be_redirect
177       end
178       after do
179         @empty_file.close true
180       end
181     end
182
183     it "should prevent upload without a password" do
184       url = upload :upload_password => ''
185       url.should be_nil
186       last_response.status.should eql(403)
187     end
188
189     it "should prevent upload with a wrong password" do
190       url = upload :upload_password => 'bad'
191       url.should be_nil
192       last_response.status.should eql(403)
193     end
194
195     context "when using AJAX to verify upload password" do
196       context "when sending the right password" do
197         before do
198           request "/authenticate", :method => "POST", :xhr => true,
199                                    :params => { :upload_password => UPLOAD_PASSWORD }
200         end
201         subject { last_response }
202         it { should be_ok }
203       end
204       context "when sending no password" do
205         before do
206           request "/authenticate", :method => "POST", :xhr => true,
207                                    :params => { :upload_password => '' }
208         end
209         subject { last_response.status }
210         it { should == 403 }
211       end
212       context "when sending a JSON dump of the wrong password" do
213         before do
214           request "/authenticate", :method => "POST", :xhr => true,
215                                    :params => { :upload_password => 'wrong'}
216         end
217         subject { last_response.status }
218         it { should == 403 }
219       end
220     end
221
222     context "when a 'one time download' has been retrieved" do
223       before(:each) do
224         @url = upload :one_time => true
225         get @url
226       end
227
228       it "should be the same as the uploaded file" do
229         last_response.should be_ok
230         last_response['Content-Type'].should eql('text/x-script.ruby')
231         last_response.body.should eql(File.new(__FILE__).read)
232       end
233
234       it "should not be downloadable any more" do
235         get @url
236         last_response.status.should eql(410)
237       end
238
239       it "should have zero'ed the file on the server" do
240         files = Dir.glob("#{Coquelicot.depot.path}/*")
241         files.should have(2).items
242         File.lstat(files[0]).size.should eql(0)
243       end
244     end
245
246     context "after a password protected upload" do
247       before(:each) do
248         @url = upload :file_key => 'somethingSecret'
249       end
250
251       it "should not return an URL with the encryption key" do
252         url_name = @url.split('/')[-1]
253         url_name.split('-').should have(1).items
254       end
255
256       it "should offer a password form before download" do
257         get @url
258         last_response.should be_ok
259         last_response['Content-Type'].should eql('text/html;charset=utf-8')
260         doc = Hpricot(last_response.body)
261         (doc/'input#file_key').should have(1).items
262       end
263
264       context "when given the correct password" do
265         it "should download the same file" do
266           post @url, :file_key => 'somethingSecret'
267           last_response.should be_ok
268           last_response['Content-Type'].should eql('text/x-script.ruby')
269           last_response.body.should eql(File.new(__FILE__).read)
270         end
271       end
272
273       it "should prevent download without a password" do
274         post @url
275         last_response.status.should eql(403)
276       end
277
278       it "should prevent download with a wrong password" do
279         post @url, :file_key => 'BAD'
280         last_response.status.should eql(403)
281       end
282     end
283
284     context "after an upload with a time limit" do
285       before(:each) do
286         @url = upload :expire => 60 # 1 hour
287       end
288
289       it "should prevent download after the time limit has expired" do
290         # let's be the day after tomorrow
291         Timecop.travel(Date.today + 2) do
292           get @url
293           last_response.status.should eql(410)
294         end
295       end
296     end
297
298     it "should refuse an expiration time longer than the maximum" do
299       upload :expire => 60 * 24 * 31 * 12 # 1 year
300       last_response.status.should eql(403)
301     end
302
303     it "should cleanup expired files" do
304       url = upload :expire => 60, :file_key => 'test' # 1 hour
305       url_name = url.split('/')[-1]
306       Dir.glob("#{Coquelicot.depot.path}/*").should have(2).items
307       # let's be the day after tomorrow
308       Timecop.travel(Date.today + 2) do
309         Coquelicot.depot.gc!
310         files = Dir.glob("#{Coquelicot.depot.path}/*")
311         files.should have(2).items
312         File.lstat(files[0]).size.should eql(0)
313         Coquelicot.depot.get_file(url_name).expired?.should be_true
314       end
315       # let's be after 'gone' period
316       Timecop.travel(Time.now + (Coquelicot.settings.gone_period * 60)) do
317         Coquelicot.depot.gc!
318         Dir.glob("#{Coquelicot.depot.path}/*").should have(0).items
319         Coquelicot.depot.get_file(url_name).should be_nil
320       end
321     end
322   end
323
324   context "when using 'imap' authentication mechanism" do
325     before(:each) do
326       app.set :authentication_method, :name => 'imap',
327                                       :imap_server => 'example.org',
328                                       :imap_port => 993
329     end
330
331     it "should try to login to the IMAP server when using AJAX" do
332       imap = stub('Net::Imap').as_null_object
333       imap.should_receive(:login).with('user', 'password')
334       Net::IMAP.should_receive(:new).with('example.org', 993, true).and_return(imap)
335
336       request "/authenticate", :method => "POST", :xhr => true,
337                                :params => { :imap_user     => 'user',
338                                             :imap_password => 'password' }
339       last_response.should be_ok
340     end
341   end
342 end