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>
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.
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.
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/>.
19 require 'multipart_parser/reader'
21 module Coquelicot::Rack
22 # Helpers method to have more readable code to test Rack responses
24 def status; self[0]; end
25 def headers; self[1]; end
26 def body; buf = ''; self[2].each { |l| buf << l }; buf; end
31 include_context 'with Coquelicot::Application'
33 let(:lower_app) { lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['Lower']] } }
34 let(:upload) { Upload.new(lower_app) }
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'
42 it 'should ensure the forwarded rack.input is rewindable' do
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']]
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)
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'
60 context 'when called for POST /upload' do
61 let(:env) { { 'SERVER_NAME' => 'example.org',
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)
69 context 'when rack.input is rewindable' do
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)
78 # second request, to be sure the warning will show up only once
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)
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
96 Content-Disposition: form-data; name="upload_password"
100 Content-Disposition: form-data; name="expire"
104 Content-Disposition: form-data; name="file_key"
108 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
109 Content-Type: text/plain
113 Content-Disposition: form-data; name="submit"
121 context 'when options are correct' do
122 include_context 'correct POST data'
124 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
126 it 'should issue a temporary redirect' do
127 expect(subject.status).to satisfy{|s| [302,303].include?(s) }
129 it 'should redirect to the ready page' do
130 expect(subject.headers['Location']).to match %r{http://example\.org/ready/}
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)).
139 it 'should increment the depot size' do
140 expect { subject }.to change { Coquelicot.depot.size }.by(1)
143 context 'when file is bigger than limit' do
144 include_context 'correct POST data'
146 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
147 allow(Coquelicot.settings).to receive(:max_file_size).and_return(100)
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
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')
156 it 'should display the maximum file size' do
157 expect(subject.body).to include('100 B')
160 context 'when there is no request Content-Length header' do
162 env['CONTENT_LENGTH'] = nil
164 it 'should bail out with 413 (Request Entity Too Large)' do
165 expect(subject.status).to be == 413
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')
170 it 'should display the maximum file size' do
171 expect(subject.body).to include('100 B')
174 context 'when the request Content-Length header is lying to us' do
176 env['CONTENT_LENGTH'] = 99
178 it 'should bail out with 413 (Request Entity Too Large)' do
179 expect(subject.status).to be == 413
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')
184 it 'should display the maximum file size' do
185 expect(subject.body).to include('100 B')
189 context 'when receiving a request with other fields after file' do
191 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
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
198 Content-Disposition: form-data; name="upload_password"
202 Content-Disposition: form-data; name="file_key"
206 Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
207 Content-Type: text/plain
211 Content-Disposition: form-data; name="submit"
215 Content-Disposition: form-data; name="i_should_not_appear_here"
221 it 'should bail out with code 400 (Bad Request)' do
222 subject.status == 400
224 it 'should display "Bad Request: fields in unacceptable order"' do
225 expect(subject.body).to include('Bad Request: fields in unacceptable order')
228 context 'when authentication fails' do
229 include_context 'correct POST data'
231 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(false)
233 it 'should bail out with code 403 (Forbidden)' do
234 subject.status == 403
236 it 'should display "Forbidden"' do
237 expect(subject.body).to include('Forbidden')
239 it 'should not add a file' do
240 expect { subject }.to_not change { Coquelicot.depot.size }
243 context 'when authentication is impossible' do
244 include_context 'correct POST data'
246 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_raise(
247 Coquelicot::Auth::Error.new('Something bad happened!'))
249 it 'should bail out with code 503 (Service Unavailable)' do
250 subject.status == 503
252 it 'should display the error message' do
253 expect(subject.body).to include('Something bad happened!')
255 it 'should not add a file' do
256 expect { subject }.to_not change { Coquelicot.depot.size }
259 context 'when no file has been submitted' do
260 let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n")
262 Content-Disposition: form-data; name="upload_password"
266 Content-Disposition: form-data; name="expire"
270 Content-Disposition: form-data; name="one_time"
274 Content-Disposition: form-data; name="submit"
281 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
283 it 'should pass to the lower app' do
284 expect(subject.body).to be == 'Lower'
286 it 'should set X_COQUELICOT_FORWARD in env' do
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)
293 it 'should forward interesting params' do
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']]
302 Upload.new(mock_app).call(env)
304 it 'should not add a file' do
305 expect { subject }.to_not change { Coquelicot.depot.size }
308 context 'when the expiration time is bigger than allowed' do
309 include_context 'correct POST data'
311 allow(Coquelicot.settings.authenticator).to receive(:authenticate).and_return(true)
312 allow(Coquelicot.settings).to receive(:maximum_expire).and_return(5)
314 it 'should bail out with 403 (Forbidden)' do
315 subject.status == 403
317 it 'should display "Forbidden: expiration time too big"' do
318 expect(subject.body).to include('Forbidden: expiration time too big')
320 it 'should not add a file' do
321 expect { subject }.to_not change { Coquelicot.depot.size }