# frozen_string_literal: true require "bundler/vendored_net_http" require "bundler/compact_index_client" require "bundler/compact_index_client/updater" require "tmpdir" RSpec.describe Bundler::CompactIndexClient::Updater do subject(:updater) { described_class.new(fetcher) } let(:fetcher) { double(:fetcher) } let(:local_path) { Pathname.new(Dir.mktmpdir("localpath")).join("versions") } let(:etag_path) { Pathname.new(Dir.mktmpdir("localpath-etags")).join("versions.etag") } let(:remote_path) { double(:remote_path) } let(:full_body) { "abc123" } let(:response) { double(:response, body: full_body, is_a?: false) } let(:digest) { Digest::SHA256.base64digest(full_body) } context "when the local path does not exist" do before do allow(response).to receive(:[]).with("Repr-Digest") { nil } allow(response).to receive(:[]).with("Digest") { nil } allow(response).to receive(:[]).with("ETag") { '"thisisanetag"' } end it "downloads the file without attempting append" do expect(fetcher).to receive(:call).once.with(remote_path, {}) { response } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("thisisanetag") end it "fails immediately on bad checksum" do expect(fetcher).to receive(:call).once.with(remote_path, {}) { response } allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:baddigest:" } expect do updater.update(remote_path, local_path, etag_path) end.to raise_error(Bundler::CompactIndexClient::Updater::MismatchedChecksumError) end end context "when the local path exists" do let(:local_body) { "abc" } before do local_path.open("w") {|f| f.write(local_body) } end context "with an etag" do before do etag_path.open("w") {|f| f.write("LocalEtag") } end let(:headers) do { "If-None-Match" => '"LocalEtag"', "Range" => "bytes=2-", } end it "does nothing if etags match" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { false } allow(response).to receive(:is_a?).with(Gem::Net::HTTPNotModified) { true } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq("abc") expect(etag_path.read).to eq("LocalEtag") end it "appends the file if etags do not match" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:#{digest}:" } allow(response).to receive(:[]).with("ETag") { '"NewEtag"' } allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { true } allow(response).to receive(:is_a?).with(Gem::Net::HTTPNotModified) { false } allow(response).to receive(:body) { "c123" } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("NewEtag") end it "replaces the file if response ignores range" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:#{digest}:" } allow(response).to receive(:[]).with("ETag") { '"NewEtag"' } allow(response).to receive(:body) { full_body } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("NewEtag") end it "tries the request again if the partial response fails digest check" do allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:baddigest:" } allow(response).to receive(:body) { "the beginning of the file changed" } allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { true } expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) full_response = double(:full_response, body: full_body, is_a?: false) allow(full_response).to receive(:[]).with("Repr-Digest") { "sha-256=:#{digest}:" } allow(full_response).to receive(:[]).with("ETag") { '"NewEtag"' } expect(fetcher).to receive(:call).once.with(remote_path, { "If-None-Match" => '"LocalEtag"' }).and_return(full_response) updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("NewEtag") end end context "without an etag file" do let(:headers) do { "Range" => "bytes=2-", # This MD5 feature should be deleted after sufficient time has passed since release. # From then on, requests that still don't have a saved etag will be made without this header. "If-None-Match" => %("#{Digest::MD5.hexdigest(local_body)}"), } end it "saves only the etag_path if generated etag matches" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { false } allow(response).to receive(:is_a?).with(Gem::Net::HTTPNotModified) { true } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq("abc") expect(%("#{etag_path.read}")).to eq(headers["If-None-Match"]) end it "appends the file" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:#{digest}:" } allow(response).to receive(:[]).with("ETag") { '"OpaqueEtag"' } allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { true } allow(response).to receive(:is_a?).with(Gem::Net::HTTPNotModified) { false } allow(response).to receive(:body) { "c123" } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("OpaqueEtag") end it "replaces the file on full file response that ignores range request" do expect(fetcher).to receive(:call).once.with(remote_path, headers).and_return(response) allow(response).to receive(:[]).with("Repr-Digest") { nil } allow(response).to receive(:[]).with("Digest") { nil } allow(response).to receive(:[]).with("ETag") { '"OpaqueEtag"' } allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { false } allow(response).to receive(:is_a?).with(Gem::Net::HTTPNotModified) { false } allow(response).to receive(:body) { full_body } updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("OpaqueEtag") end it "tries the request again if the partial response fails digest check" do allow(response).to receive(:[]).with("Repr-Digest") { "sha-256=:baddigest:" } allow(response).to receive(:body) { "the beginning of the file changed" } allow(response).to receive(:is_a?).with(Gem::Net::HTTPPartialContent) { true } expect(fetcher).to receive(:call).once.with(remote_path, headers) do # During the failed first request, we simulate another process writing the etag. # This ensures the second request doesn't generate the md5 etag again but just uses whatever is written. etag_path.open("w") {|f| f.write("LocalEtag") } response end full_response = double(:full_response, body: full_body, is_a?: false) allow(full_response).to receive(:[]).with("Repr-Digest") { "sha-256=:#{digest}:" } allow(full_response).to receive(:[]).with("ETag") { '"NewEtag"' } expect(fetcher).to receive(:call).once.with(remote_path, { "If-None-Match" => '"LocalEtag"' }).and_return(full_response) updater.update(remote_path, local_path, etag_path) expect(local_path.read).to eq(full_body) expect(etag_path.read).to eq("NewEtag") end end end context "when the ETag header is missing" do # Regression test for https://github.com/rubygems/bundler/issues/5463 let(:response) { double(:response, body: full_body) } it "treats the response as an update" do allow(response).to receive(:[]).with("Repr-Digest") { nil } allow(response).to receive(:[]).with("Digest") { nil } allow(response).to receive(:[]).with("ETag") { nil } expect(fetcher).to receive(:call) { response } updater.update(remote_path, local_path, etag_path) end end context "when the download is corrupt" do let(:response) { double(:response, body: "") } it "raises HTTPError" do expect(fetcher).to receive(:call).and_raise(Zlib::GzipFile::Error) expect do updater.update(remote_path, local_path, etag_path) end.to raise_error(Bundler::HTTPError) end end context "when receiving non UTF-8 data and default internal encoding set to ASCII" do let(:response) { double(:response, body: "\x8B".b) } it "works just fine" do old_verbose = $VERBOSE previous_internal_encoding = Encoding.default_internal begin $VERBOSE = false Encoding.default_internal = "ASCII" allow(response).to receive(:[]).with("Repr-Digest") { nil } allow(response).to receive(:[]).with("Digest") { nil } allow(response).to receive(:[]).with("ETag") { nil } expect(fetcher).to receive(:call) { response } updater.update(remote_path, local_path, etag_path) ensure Encoding.default_internal = previous_internal_encoding $VERBOSE = old_verbose end end end end