Fix name-typo in NEWS
[coquelicot.git] / spec / coquelicot / rack / upload_spec.rb
1 # -*- coding: UTF-8 -*-
2 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
3 # Copyright © 2012-2013 potager.org <jardiniers@potager.org>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU Affero General Public License for more details.
14 #
15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 require 'spec_helper'
19 require 'multipart_parser/reader'
20
21 module Coquelicot::Rack
22   # Helpers method to have more readable code to test Rack responses
23   module RackResponse
24     def status; self[0]; end
25     def headers; self[1]; end
26     def body; buf = ''; self[2].each { |l| buf << l }; buf; end
27   end
28
29   describe Upload do
30
31     include_context 'with Coquelicot::Application'
32
33     let(:lower_app) { lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['Lower']] } }
34     let(:upload) { Upload.new(lower_app) }
35     describe '#call' do
36       subject { upload.call(env).extend(RackResponse) }
37       context 'when receiving GET /' do
38         let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/' } }
39         it 'should pass the request to the lower app' do
40           expect(subject.body).to be == 'Lower'
41         end
42         it 'should ensure the forwarded rack.input is rewindable' do
43           spec_app = double
44           expect(spec_app).to receive(:call) do |env|
45             expect(env['rack.input']).to respond_to(:rewind)
46             [200, {'Content-Type' => 'text/plain'}, ['mock']]
47           end
48           input = StringIO.new('foo=bar&quux=blabb')
49           class << input; undef_method(:rewind); end
50           env['rack.input'] = input
51           Upload.new(spec_app).call(env)
52         end
53       end
54       context 'when called for GET /upload' do
55         let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/upload' } }
56         it 'should pass the request to the lower app' do
57           expect(subject.body).to be == 'Lower'
58         end
59       end
60       context 'when called for POST /upload' do
61         let(:env) { { 'SERVER_NAME' => 'example.org',
62                       'SERVER_PORT' => 80,
63                       'REQUEST_METHOD' => 'POST',
64                       'PATH_INFO' => '/upload',
65                       'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}",
66                       'CONTENT_LENGTH' => "#{input.size}",
67                       'rack.input' => StringIO.new(input)
68                     } }
69         context 'when rack.input is rewindable' do
70           let(:input) { '' }
71           it 'should log a warning during the first request' do
72             logger = double('Logger')
73             expect(logger).to receive(:warn).with(/rewindable/).once
74             env['rack.logger'] = logger
75             # set it to nil to stop Sinatra from messing up
76             upload = Class.new(Upload) { set :logging, nil }.new(lower_app)
77             upload.call(env.dup)
78             # second request, to be sure the warning will show up only once
79             upload.call(env)
80           end
81         end
82         context 'when receiving a request which is not multipart' do
83           let(:input) { 'foo=bar&quux=blabb' }
84           it 'should raise an error' do
85             env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
86             expect { subject }.to raise_exception(::MultipartParser::NotMultipartError)
87           end
88         end
89
90         shared_context 'correct POST data' do
91           let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
92           let(:file_content) { File.read(file) }
93           let(:file_key) { 'secret' }
94           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
95 --AaB03x
96 Content-Disposition: form-data; name="upload_password"
97
98 whatever
99 --AaB03x
100 Content-Disposition: form-data; name="expire"
101
102 60
103 --AaB03x
104 Content-Disposition: form-data; name="file_key"
105
106 #{file_key}
107 --AaB03x
108 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
109 Content-Type: text/plain
110
111 %s
112 --AaB03x
113 Content-Disposition: form-data; name="submit"
114
115 submit
116 --AaB03x--
117 MULTIPART_DATA
118           end
119         end
120
121         context 'when options are correct' do
122           include_context 'correct POST data'
123           before(:each) do
124             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
125           end
126           it 'should issue a temporary redirect' do
127             expect(subject.status).to satisfy{|s| [302,303].include?(s) }
128           end
129           it 'should redirect to the ready page' do
130             expect(subject.headers['Location']).to match %r{http://example\.org/ready/}
131           end
132           it 'should add a file to the depot' do
133             filename = File.basename(file)
134             expect(Coquelicot.depot).to receive(:add_file).
135                 with(file_key, hash_including('Filename' => filename)).
136                 and_yield.and_yield
137             subject
138           end
139           it 'should increment the depot size' do
140             expect { subject }.to change { Coquelicot.depot.size }.by(1)
141           end
142         end
143         context 'when file is bigger than limit' do
144           include_context 'correct POST data'
145           before(:each) do
146             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
147             allow(Coquelicot.settings).to receive(:max_file_size).and_return(100)
148           end
149           context 'when there is a request Content-Length header' do
150             it 'should bail out with 413 (Request Entity Too Large)' do
151               expect(subject.status).to be == 413
152             end
153             it 'should display "File is bigger than maximum allowed size"' do
154               expect(subject.body).to include('File is bigger than maximum allowed size')
155             end
156             it 'should display the maximum file size' do
157               expect(subject.body).to include('100 B')
158             end
159           end
160           context 'when there is no request Content-Length header' do
161             before(:each) do
162               env['CONTENT_LENGTH'] = nil
163             end
164             it 'should bail out with 413 (Request Entity Too Large)' do
165               expect(subject.status).to be == 413
166             end
167             it 'should display "File is bigger than maximum allowed size"' do
168               expect(subject.body).to include('File is bigger than maximum allowed size')
169             end
170             it 'should display the maximum file size' do
171               expect(subject.body).to include('100 B')
172             end
173           end
174           context 'when the request Content-Length header is lying to us' do
175             before(:each) do
176               env['CONTENT_LENGTH'] = 99
177             end
178             it 'should bail out with 413 (Request Entity Too Large)' do
179               expect(subject.status).to be == 413
180             end
181             it 'should display "File is bigger than maximum allowed size"' do
182               expect(subject.body).to include('File is bigger than maximum allowed size')
183             end
184             it 'should display the maximum file size' do
185               expect(subject.body).to include('100 B')
186             end
187           end
188         end
189         context 'when receiving a request with other fields after file' do
190           before(:each) do
191             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
192           end
193           let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
194           let(:file_content) { File.read(file) }
195           let(:file_key) { 'secret' }
196           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
197 --AaB03x
198 Content-Disposition: form-data; name="upload_password"
199
200 whatever
201 --AaB03x
202 Content-Disposition: form-data; name="file_key"
203
204 #{file_key}
205 --AaB03x
206 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
207 Content-Type: text/plain
208
209 %s
210 --AaB03x
211 Content-Disposition: form-data; name="submit"
212
213 submit
214 --AaB03x
215 Content-Disposition: form-data; name="i_should_not_appear_here"
216
217 whatever
218 --AaB03x--
219 MULTIPART_DATA
220           end
221           it 'should bail out with code 400 (Bad Request)' do
222             subject.status == 400
223           end
224           it 'should display "Bad Request: fields in unacceptable order"' do
225             expect(subject.body).to include('Bad Request: fields in unacceptable order')
226           end
227         end
228         context 'when authentication fails' do
229           include_context 'correct POST data'
230           before(:each) do
231             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(false)
232           end
233           it 'should bail out with code 403 (Forbidden)' do
234             subject.status == 403
235           end
236           it 'should display "Forbidden"' do
237             expect(subject.body).to include('Forbidden')
238           end
239           it 'should not add a file' do
240             expect { subject }.to_not change { Coquelicot.depot.size }
241           end
242         end
243         context 'when authentication is impossible' do
244           include_context 'correct POST data'
245           before(:each) do
246             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_raise(
247               Coquelicot::Auth::Error.new('Something bad happened!'))
248           end
249           it 'should bail out with code 503 (Service Unavailable)' do
250             subject.status == 503
251           end
252           it 'should display the error message' do
253             expect(subject.body).to include('Something bad happened!')
254           end
255           it 'should not add a file' do
256             expect { subject }.to_not change { Coquelicot.depot.size }
257           end
258         end
259         context 'when no file has been submitted' do
260           let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n")
261 --AaB03x
262 Content-Disposition: form-data; name="upload_password"
263
264 whatever
265 --AaB03x
266 Content-Disposition: form-data; name="expire"
267
268 60
269 --AaB03x
270 Content-Disposition: form-data; name="one_time"
271
272 true
273 --AaB03x
274 Content-Disposition: form-data; name="submit"
275
276 submit
277 --AaB03x--
278 MULTIPART_DATA
279           end
280           before(:each) do
281             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
282           end
283           it 'should pass to the lower app' do
284             expect(subject.body).to be == 'Lower'
285           end
286           it 'should set X_COQUELICOT_FORWARD in env' do
287             mock_app = double
288             expect(mock_app).to receive(:call).
289                 with(hash_including('X_COQUELICOT_FORWARD')).
290                 and_return([200, {'Content-Type' => 'text/plain'}, ['forward mock']])
291             Upload.new(mock_app).call(env)
292           end
293           it 'should forward interesting params' do
294             mock_app = double
295             expect(mock_app).to receive(:call) do
296               request = Sinatra::Request.new(env)
297               expect(request.params['upload_password']).to be == 'whatever'
298               expect(request.params['expire']).to be == '60'
299               expect(request.params['one_time']).to be == 'true'
300               [200, {'Content-Type' => 'text/plain'}, ['forward mock']]
301             end
302             Upload.new(mock_app).call(env)
303           end
304           it 'should not add a file' do
305             expect { subject }.to_not change { Coquelicot.depot.size }
306           end
307         end
308         context 'when the expiration time is bigger than allowed' do
309           include_context 'correct POST data'
310           before(:each) do
311             allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
312             allow(Coquelicot.settings).to receive(:maximum_expire).and_return(5)
313           end
314           it 'should bail out with 403 (Forbidden)' do
315             subject.status == 403
316           end
317           it 'should display "Forbidden: expiration time too big"' do
318             expect(subject.body).to include('Forbidden: expiration time too big')
319           end
320           it 'should not add a file' do
321             expect { subject }.to_not change { Coquelicot.depot.size }
322           end
323         end
324       end
325     end
326   end
327 end