properly delete file in case of errors in StoredFile.create
[coquelicot.git] / spec / coquelicot / stored_file_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 require 'tmpdir'
19 require 'yaml'
20 require 'timecop'
21 require 'base64'
22
23 module Coquelicot
24   describe StoredFile do
25
26     shared_context 'create new StoredFile' do
27       around do |example|
28         Dir.mktmpdir('coquelicot') do |tmpdir|
29           @tmpdir = tmpdir
30           example.run
31         end
32       end
33
34       def create_stored_file(extra_meta = {})
35         @stored_file_path ||= File.expand_path('stored_file', @tmpdir)
36         @pass = 'secret'
37         @src = __FILE__
38         @src_length = File.stat(@src).size
39         meta = { 'Expire-at' => 0, 'Length' => @src_length }
40         meta.merge!(extra_meta)
41         content = File.read(@src)
42         StoredFile.create(@stored_file_path, @pass, meta) do
43           buf, content = content, nil
44           buf
45         end
46       end
47     end
48
49     describe '.get_cipher' do
50       context 'when given an unknown method' do
51         it 'should raise an error' do
52           expect {
53             StoredFile.get_cipher('secret', 'salt', :whatever)
54           }.to raise_error(NameError)
55         end
56       end
57       [ :encrypt, :decrypt ].each do |method|
58         let(:key_len)  { 32 } # this is AES-256-CBC
59         let(:iv_len)   { 16 } # this is AES-256-CBC
60         let(:hmac_len) { key_len + iv_len }
61         let(:hmac) { (1..hmac_len).to_a.collect { |c| c.chr }.join }
62         context "when given #{method} as method" do
63           it 'should use PKCS5.pbkdf2_hmac_sha1' do
64             OpenSSL::PKCS5.should_receive(:pbkdf2_hmac_sha1).
65               with('secret', 'salt', 2000, hmac_len).
66               and_return(hmac)
67             StoredFile.get_cipher('secret', 'salt', method)
68           end
69           it 'should set the key to lower part of the HMAC' do
70             OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
71               and_return(hmac)
72             cipher = OpenSSL::Cipher.new 'AES-256-CBC'
73             cipher.should_receive(:key=).with(hmac[0..key_len-1])
74             OpenSSL::Cipher.stub(:new).and_return(cipher)
75             StoredFile.get_cipher('secret', 'salt', method)
76           end
77           it 'should set the IV to the higher part of the HMAC' do
78             OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
79               and_return(hmac)
80             cipher = OpenSSL::Cipher.new 'AES-256-CBC'
81             cipher.should_receive(:iv=).with(hmac[key_len..-1])
82             OpenSSL::Cipher.stub(:new).and_return(cipher)
83             StoredFile.get_cipher('secret', 'salt', method)
84           end
85           it 'should return an OpenSSL::Cipher' do
86             cipher = StoredFile.get_cipher('secret', 'salt', method)
87             cipher.should be_a(OpenSSL::Cipher)
88           end
89         end
90       end
91     end
92
93     describe '.gen_salt' do
94       it 'should return a string of proper length' do
95         StoredFile.gen_salt.length == StoredFile::SALT_LEN
96       end
97       it 'should call OpenSSL::Random every time' do
98         OpenSSL::Random.should_receive(:random_bytes).
99             and_return(1, 2)
100         StoredFile.gen_salt == 1
101         StoredFile.gen_salt == 2
102       end
103     end
104
105     let(:stored_file_path) {
106       File.expand_path('../../fixtures/LICENSE-secret-1.0/stored_file', __FILE__)
107     }
108     let(:reference) {
109       YAML.load_file(File.expand_path('../../fixtures/LICENSE-secret-1.0/reference', __FILE__))
110     }
111
112     describe '.open' do
113       context 'when the given file does not exist' do
114         it 'should raise an error' do
115           expect {
116             StoredFile.open('/nonexistent')
117           }.to raise_error(Errno::ENOENT)
118         end
119       end
120       context 'when the file is not a StoredFile' do
121         it 'should raise an error' do
122           expect {
123             StoredFile.open(__FILE__)
124           }.to raise_error
125           # XXX: make this an ArgumentError
126         end
127       end
128       context 'when giving no pass' do
129         subject { StoredFile.open(stored_file_path) }
130         it 'should read clear metadata' do
131           subject.meta['Coquelicot'] == '1.0'
132         end
133         # XXX: maybe we want a way to know that we can't uncrypt the rest
134       end
135       context 'when giving a wrong pass' do
136         it 'should raise an error' do
137           expect {
138             StoredFile.open(stored_file_path, 'whatever')
139           }.to raise_error(BadKey)
140         end
141       end
142       context 'when giving the right pass' do
143         subject { StoredFile.open(stored_file_path, 'secret') }
144         it 'should read the metadata' do
145           subject.meta['Length'] == reference['Length']
146         end
147       end
148     end
149
150     describe '.create' do
151       include_context 'create new StoredFile'
152       context 'when the destination file already exists' do
153         it 'should raise an error' do
154           @stored_file_path = File.expand_path('stored_file', @tmpdir)
155           FileUtils.touch @stored_file_path
156           expect {
157             create_stored_file
158           }.to raise_error(Errno::EEXIST)
159         end
160       end
161       context 'in clear metadata' do
162         let(:test_salt) { "\0" * StoredFile::SALT_LEN }
163         let(:expire_at) { Time.now + 60 }
164         before(:each) do
165           StoredFile.stub(:gen_salt).and_return(test_salt)
166           create_stored_file('Expire-at' => expire_at)
167         end
168         let(:clear_meta) { YAML.load_file(@stored_file_path) }
169         it 'should write Coquelicot file version' do
170           clear_meta['Coquelicot'].should == '1.0'
171         end
172         it 'should generate a random Salt' do
173           salt = Base64.decode64(clear_meta['Salt'])
174           salt.should == test_salt
175         end
176         it 'should record expiration time' do
177           clear_meta['Expire-at'].should == expire_at
178         end
179       end
180       context 'in encrypted part' do
181         before(:each) do
182           @cipher = Array.new
183           class << @cipher
184             def update(str); self << str; end
185             attr_reader :content
186             def final; @content = self.join; end
187           end
188           StoredFile.stub(:get_cipher).and_return(@cipher)
189         end
190         it 'should start with metadata as YAML block' do
191           create_stored_file
192           YAML.load(@cipher.content).should be_a(Hash)
193         end
194         context 'in encrypted metadata' do
195           before(:each) do
196             create_stored_file
197             @meta = YAML.load(@cipher.content)
198           end
199           it 'should contain Length' do
200             @meta['Length'].should == @src_length
201           end
202           it 'should Created-at' do
203             @meta.should include('Created-at')
204           end
205         end
206         it 'should follow metadata with file content' do
207           create_stored_file
208           @cipher.content.split(/^--- \n/, 3)[-1].should == File.read(@src)
209         end
210       end
211       context 'when the given block raise an error' do
212         it 'should not create a file' do
213           path = File.expand_path('stored_file', @tmpdir)
214           begin
215             StoredFile.create(path, 'secret', {}) do
216               raise StandardError.new
217             end
218           rescue StandardError
219             # that was expected!
220           end
221           File.should_not exist(path)
222         end
223       end
224     end
225
226     describe '#created_at' do
227       include_context 'create new StoredFile'
228       it 'should return the creation time' do
229         Timecop.freeze(2012, 1, 1) do
230           create_stored_file
231           stored_file = StoredFile.open(@stored_file_path, @pass)
232           stored_file.created_at.should == Time.local(2012, 1, 1)
233         end
234       end
235     end
236
237     describe '#expire_at' do
238       include_context 'create new StoredFile'
239       it 'should return the date of expiration' do
240         create_stored_file('Expire-at' => Time.local(2012, 1, 1))
241         stored_file = StoredFile.open(@stored_file_path, @pass)
242         stored_file.expire_at.should == Time.local(2012, 1, 1)
243       end
244     end
245
246     describe '#expired?' do
247       include_context 'create new StoredFile'
248       context 'when expiration time is in the past' do
249         it 'should return true' do
250           Timecop.freeze do
251             create_stored_file('Expire-at' => Time.now - 60)
252             stored_file = StoredFile.open(@stored_file_path, @pass)
253             stored_file.should be_expired
254           end
255         end
256       end
257       context 'when expiration time is in the future' do
258         it 'should return false' do
259           Timecop.freeze do
260             create_stored_file('Expire-at' => Time.now + 60)
261             stored_file = StoredFile.open(@stored_file_path, @pass)
262             stored_file.should_not be_expired
263           end
264         end
265       end
266     end
267
268     describe '#one_time_only?' do
269       include_context 'create new StoredFile'
270       context 'when file is labelled as "one time only"' do
271         it 'should be true' do
272           create_stored_file('One-time-only' => true)
273           stored_file = StoredFile.open(@stored_file_path, @pass)
274           stored_file.should be_one_time_only
275         end
276       end
277       context 'when file is not labelled as "one time only"' do
278         it 'should be false' do
279           create_stored_file
280           stored_file = StoredFile.open(@stored_file_path, @pass)
281           stored_file.should_not be_one_time_only
282         end
283       end
284     end
285
286     describe '#empty!' do
287       include_context 'create new StoredFile'
288       before(:each) do
289         create_stored_file
290         @stored_file = StoredFile.open(@stored_file_path, @pass)
291       end
292       it 'should overwrite the file content with \0' do
293         file = StringIO.new(File.read(@src))
294         File.should_receive(:open).
295             with(@stored_file_path, anything).
296             and_yield(file)
297         @stored_file.empty!
298         file.string.should == "\0" * File.stat(@src).size
299       end
300       it 'should truncate the file' do
301         @stored_file.empty!
302         File.stat(@stored_file_path).size.should == 0
303       end
304     end
305
306     describe '#lockfile' do
307       let(:stored_file) { StoredFile.open(stored_file_path, 'secret') }
308       it 'should return a Lockfile' do
309         stored_file.lockfile.should be_a(Lockfile)
310       end
311       it 'should create a Lockfile using the path followed by ".lock"' do
312         Lockfile.should_receive(:new) do |path, options|
313           path.should == "#{stored_file_path}.lock"
314         end
315         stored_file.lockfile
316       end
317     end
318
319     describe '#each' do
320       context 'when the right pass has been given' do
321         let(:stored_file) { StoredFile.open(stored_file_path, 'secret') }
322         it 'should output the whole content with several yields' do
323           buf = ''
324           stored_file.each do |data|
325             buf << data
326           end
327           buf.should == reference['Content']
328         end
329       end
330       context 'when no password has been given' do
331         let(:stored_file) { StoredFile.open(stored_file_path) }
332         it 'should raise BadKey' do
333           expect {
334             stored_file.each
335           }.to raise_error(BadKey)
336         end
337       end
338     end
339
340     describe '#close' do
341       it 'should reset the cipher' do
342         salt = Base64::decode64(YAML.load_file(stored_file_path)['Salt'])
343         cipher = StoredFile.get_cipher('secret', salt, :decrypt)
344         StoredFile.stub(:get_cipher).and_return(cipher)
345
346         stored_file = StoredFile.open(stored_file_path, 'secret')
347         cipher.should_receive(:reset)
348         stored_file.close
349       end
350       context 'when file is "one-time only"' do
351         include_context 'create new StoredFile'
352         before(:each) do
353           create_stored_file('One-time-only' => true)
354           @stored_file = StoredFile.open(@stored_file_path, @pass)
355           # XXX: that is not a nice assumption (at all)
356           @stored_file.lockfile.lock
357         end
358         context 'when the file has not been fully sent' do
359           it 'should leave the content untouched' do
360             begin
361               @stored_file.each { |data| raise StandardError }
362             rescue
363               # do nothing
364             end
365             @stored_file.close
366             another = StoredFile.open(@stored_file_path, @pass)
367             buf = ''
368             another.each { |data| buf << data }
369             buf.should == File.read(@src)
370           end
371         end
372         context 'when the file has been fully sent' do
373           before(:each) do
374             # read entirely
375             @stored_file.each { |data| nil }
376           end
377           it 'should empty the file' do
378             @stored_file.should_receive(:empty!)
379             @stored_file.close
380           end
381         end
382       end
383     end
384   end
385 end