allow to limit file size through the max_file_size setting
[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 file is bigger than limit' do
143           include_context 'correct POST data'
144           before(:each) do
145             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
146             Coquelicot.settings.stub(:max_file_size).and_return(100)
147           end
148           context 'when there is a request Content-Length header' do
149             it 'should bail out with 413 (Request Entity Too Large)' do
150               subject.status.should == 413
151             end
152             it 'should display "File is bigger than maximum allowed size"' do
153               subject.body.should include('File is bigger than maximum allowed size')
154             end
155             it 'should display the maximum file size' do
156               subject.body.should include('100 B')
157             end
158           end
159           context 'when there is no request Content-Length header' do
160             before(:each) do
161               env['CONTENT_LENGTH'] = nil
162             end
163             it 'should bail out with 413 (Request Entity Too Large)' do
164               subject.status.should == 413
165             end
166             it 'should display "File is bigger than maximum allowed size"' do
167               subject.body.should include('File is bigger than maximum allowed size')
168             end
169             it 'should display the maximum file size' do
170               subject.body.should include('100 B')
171             end
172           end
173           context 'when the request Content-Length header is lying to us' do
174             before(:each) do
175               env['CONTENT_LENGTH'] = 99
176             end
177             it 'should bail out with 413 (Request Entity Too Large)' do
178               subject.status.should == 413
179             end
180             it 'should display "File is bigger than maximum allowed size"' do
181               subject.body.should include('File is bigger than maximum allowed size')
182             end
183             it 'should display the maximum file size' do
184               subject.body.should include('100 B')
185             end
186           end
187         end
188         context 'when receiving a request with other fields after file' do
189           before(:each) do
190             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
191           end
192           let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
193           let(:file_content) { File.read(file) }
194           let(:file_key) { 'secret' }
195           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
196 --AaB03x
197 Content-Disposition: form-data; name="upload_password"
198
199 whatever
200 --AaB03x
201 Content-Disposition: form-data; name="file_key"
202
203 #{file_key}
204 --AaB03x
205 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
206 Content-Type: text/plain
207
208 %s
209 --AaB03x
210 Content-Disposition: form-data; name="submit"
211
212 submit
213 --AaB03x
214 Content-Disposition: form-data; name="i_should_not_appear_here"
215
216 whatever
217 --AaB03x--
218 MULTIPART_DATA
219           end
220           it 'should bail out with code 400 (Bad Request)' do
221             subject.status == 400
222           end
223           it 'should display "Bad Request: fields in unacceptable order"' do
224             subject.body.should == 'Bad Request: fields in unacceptable order'
225           end
226         end
227         context 'when authentication fails' do
228           include_context 'correct POST data'
229           before(:each) do
230             Coquelicot.settings.authenticator.stub(:authenticate).and_return(false)
231           end
232           it 'should bail out with code 403 (Forbidden)' do
233             subject.status == 403
234           end
235           it 'should display "Forbidden"' do
236             subject.body.should == 'Forbidden'
237           end
238           it 'should not add a file' do
239             expect { subject }.to_not change { Coquelicot.depot.size }
240           end
241         end
242         context 'when authentication is impossible' do
243           include_context 'correct POST data'
244           before(:each) do
245             Coquelicot.settings.authenticator.stub(:authenticate).and_raise(
246               Coquelicot::Auth::Error.new('Something bad happened!'))
247           end
248           it 'should bail out with code 503 (Service Unavailable)' do
249             subject.status == 503
250           end
251           it 'should display the error message' do
252             subject.body.should == 'Something bad happened!'
253           end
254           it 'should not add a file' do
255             expect { subject }.to_not change { Coquelicot.depot.size }
256           end
257         end
258         context 'when no file has been submitted' do
259           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n")
260 --AaB03x
261 Content-Disposition: form-data; name="upload_password"
262
263 whatever
264 --AaB03x
265 Content-Disposition: form-data; name="expire"
266
267 60
268 --AaB03x
269 Content-Disposition: form-data; name="one_time"
270
271 true
272 --AaB03x
273 Content-Disposition: form-data; name="submit"
274
275 submit
276 --AaB03x--
277 MULTIPART_DATA
278           end
279           before(:each) do
280             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
281           end
282           it 'should pass to the lower app' do
283             subject.body.should == 'Lower'
284           end
285           it 'should set X_COQUELICOT_FORWARD in env' do
286             mock_app = double
287             mock_app.should_receive(:call).
288                 with(hash_including('X_COQUELICOT_FORWARD')).
289                 and_return([200, {'Content-Type' => 'text/plain'}, ['forward mock']])
290             Upload.new(mock_app).call(env)
291           end
292           it 'should forward interesting params' do
293             mock_app = double
294             mock_app.should_receive(:call) do
295               request = Sinatra::Request.new(env)
296               request.params['upload_password'].should == 'whatever'
297               request.params['expire'].should == '60'
298               request.params['one_time'].should == 'true'
299               [200, {'Content-Type' => 'text/plain'}, ['forward mock']]
300             end
301             Upload.new(mock_app).call(env)
302           end
303           it 'should not add a file' do
304             expect { subject }.to_not change { Coquelicot.depot.size }
305           end
306         end
307         context 'when the expiration time is bigger than allowed' do
308           include_context 'correct POST data'
309           before(:each) do
310             Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
311             Coquelicot.settings.stub(:maximum_expire).and_return(5)
312           end
313           it 'should bail out with 403 (Forbidden)' do
314             subject.status == 403
315           end
316           it 'should display "Forbidden: expiration time too big"' do
317             subject.body.should == 'Forbidden: expiration time too big'
318           end
319           it 'should not add a file' do
320             expect { subject }.to_not change { Coquelicot.depot.size }
321           end
322         end
323       end
324     end
325   end
326 end