properly handle large file uploads
[coquelicot.git] / spec / coquelicot / rack / upload_spec.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 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 require 'multipart_parser/reader'
19
20 module Coquelicot::Rack
21   # Helpers method to have more readable code to test Rack responses
22   module RackResponse
23     def status; self[0]; end
24     def headers; self[1]; end
25     def body; buf = ''; self[2].each { |l| buf << l }; buf; end
26   end
27
28   describe Upload do
29
30     include_context 'with Coquelicot::Application'
31
32     let(:lower_app) { lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['Lower']] } }
33     let(:upload) { Upload.new(lower_app) }
34     describe '#call' do
35       subject { upload.call(env).extend(RackResponse) }
36       context 'when receiving GET /' do
37         let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/' } }
38         it 'should pass the request to the lower app' do
39           subject.body.should == 'Lower'
40         end
41         it 'should ensure the forwarded rack.input is rewindable' do
42           spec_app = double
43           spec_app.should_receive(:call) do |env|
44             env['rack.input'].should respond_to(:rewind)
45             [200, {'Content-Type' => 'text/plain'}, ['mock']]
46           end
47           input = StringIO.new('foo=bar&quux=blabb')
48           class << input; undef_method(:rewind); end
49           env['rack.input'] = input
50           Upload.new(spec_app).call(env)
51         end
52       end
53       context 'when called for GET /upload' do
54         let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/upload' } }
55         it 'should pass the request to the lower app' do
56           subject.body.should == 'Lower'
57         end
58       end
59       context 'when called for POST /upload' do
60         let(:env) { { 'SERVER_NAME' => 'example.org',
61                       'SERVER_PORT' => 80,
62                       'REQUEST_METHOD' => 'POST',
63                       'PATH_INFO' => '/upload',
64                       'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}",
65                       'CONTENT_LENGTH' => "#{input.size}",
66                       'rack.input' => StringIO.new(input)
67                     } }
68         context 'when rack.input is rewindable' do
69           let(:input) { '' }
70           it 'should log a warning during the first request' do
71             logger = double('Logger')
72             logger.should_receive(:warn).with(/rewindable/).once
73             env['rack.logger'] = logger
74             # set it to nil to stop Sinatra from messing up
75             upload = Class.new(Upload) { set :logging, nil }.new(lower_app)
76             upload.call(env.dup)
77             # second request, to be sure the warning will show up only once
78             upload.call(env)
79           end
80         end
81         context 'when receiving a request which is not multipart' do
82           let(:input) { 'foo=bar&quux=blabb' }
83           it 'should raise an error' do
84             env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
85             expect { subject }.to raise_exception(::MultipartParser::NotMultipartError)
86           end
87         end
88
89         shared_context 'correct POST data' do
90           let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
91           let(:file_content) { File.read(file) }
92           let(:file_key) { 'secret' }
93           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
94 --AaB03x
95 Content-Disposition: form-data; name="upload_password"
96
97 whatever
98 --AaB03x
99 Content-Disposition: form-data; name="expire"
100
101 60
102 --AaB03x
103 Content-Disposition: form-data; name="file_key"
104
105 #{file_key}
106 --AaB03x
107 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
108 Content-Type: text/plain
109
110 %s
111 --AaB03x
112 Content-Disposition: form-data; name="submit"
113
114 submit
115 --AaB03x--
116 MULTIPART_DATA
117           end
118         end
119
120         context 'when options are correct' do
121           include_context 'correct POST data'
122           before(:each) do
123             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
124           end
125           it 'should issue a temporary redirect' do
126             subject.status.should satisfy{|s| [302,303].include?(s) }
127           end
128           it 'should redirect to the ready page' do
129             subject.headers['Location'].should =~ %r{http://example\.org/ready/}
130           end
131           it 'should add a file to the depot' do
132             filename = File.basename(file)
133             Coquelicot.depot.should_receive(:add_file).
134                 with(file_key, hash_including('Filename' => filename)).
135                 and_yield(file_content).and_yield(nil)
136             subject
137           end
138           it 'should increment the depot size' do
139             expect { subject }.to change { Coquelicot.depot.size }.by(1)
140           end
141         end
142         context 'when receiving a request with other fields after file' do
143           before(:each) do
144             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
145           end
146           let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
147           let(:file_content) { File.read(file) }
148           let(:file_key) { 'secret' }
149           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
150 --AaB03x
151 Content-Disposition: form-data; name="upload_password"
152
153 whatever
154 --AaB03x
155 Content-Disposition: form-data; name="file_key"
156
157 #{file_key}
158 --AaB03x
159 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
160 Content-Type: text/plain
161
162 %s
163 --AaB03x
164 Content-Disposition: form-data; name="submit"
165
166 submit
167 --AaB03x
168 Content-Disposition: form-data; name="i_should_not_appear_here"
169
170 whatever
171 --AaB03x--
172 MULTIPART_DATA
173           end
174           it 'should bail out with code 400 (Bad Request)' do
175             subject.status == 400
176           end
177           it 'should display "Bad Request: fields in unacceptable order"' do
178             subject.body.should == 'Bad Request: fields in unacceptable order'
179           end
180         end
181         context 'when authentication fails' do
182           include_context 'correct POST data'
183           before(:each) do
184             Coquelicot.settings.authenticator.stub(:authenticate).and_return(false)
185           end
186           it 'should bail out with code 403 (Forbidden)' do
187             subject.status == 403
188           end
189           it 'should display "Forbidden"' do
190             subject.body.should == 'Forbidden'
191           end
192           it 'should not add a file' do
193             expect { subject }.to_not change { Coquelicot.depot.size }
194           end
195         end
196         context 'when authentication is impossible' do
197           include_context 'correct POST data'
198           before(:each) do
199             Coquelicot.settings.authenticator.stub(:authenticate).and_raise(
200               Coquelicot::Auth::Error.new('Something bad happened!'))
201           end
202           it 'should bail out with code 503 (Service Unavailable)' do
203             subject.status == 503
204           end
205           it 'should display the error message' do
206             subject.body.should == 'Something bad happened!'
207           end
208           it 'should not add a file' do
209             expect { subject }.to_not change { Coquelicot.depot.size }
210           end
211         end
212         context 'when no file has been submitted' do
213           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n")
214 --AaB03x
215 Content-Disposition: form-data; name="upload_password"
216
217 whatever
218 --AaB03x
219 Content-Disposition: form-data; name="expire"
220
221 60
222 --AaB03x
223 Content-Disposition: form-data; name="one_time"
224
225 true
226 --AaB03x
227 Content-Disposition: form-data; name="submit"
228
229 submit
230 --AaB03x--
231 MULTIPART_DATA
232           end
233           before(:each) do
234             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
235           end
236           it 'should pass to the lower app' do
237             subject.body.should == 'Lower'
238           end
239           it 'should set X_COQUELICOT_FORWARD in env' do
240             mock_app = double
241             mock_app.should_receive(:call).
242                 with(hash_including('X_COQUELICOT_FORWARD')).
243                 and_return([200, {'Content-Type' => 'text/plain'}, ['forward mock']])
244             Upload.new(mock_app).call(env)
245           end
246           it 'should forward interesting params' do
247             mock_app = double
248             mock_app.should_receive(:call) do
249               request = Sinatra::Request.new(env)
250               request.params['upload_password'].should == 'whatever'
251               request.params['expire'].should == '60'
252               request.params['one_time'].should == 'true'
253               [200, {'Content-Type' => 'text/plain'}, ['forward mock']]
254             end
255             Upload.new(mock_app).call(env)
256           end
257           it 'should not add a file' do
258             expect { subject }.to_not change { Coquelicot.depot.size }
259           end
260         end
261         context 'when the expiration time is bigger than allowed' do
262           include_context 'correct POST data'
263           before(:each) do
264             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
265             Coquelicot.settings.stub(:maximum_expire).and_return(5)
266           end
267           it 'should bail out with 403 (Forbidden)' do
268             subject.status == 403
269           end
270           it 'should display "Forbidden: expiration time too big"' do
271             subject.body.should == 'Forbidden: expiration time too big'
272           end
273           it 'should not add a file' do
274             expect { subject }.to_not change { Coquelicot.depot.size }
275           end
276         end
277       end
278     end
279   end
280 end