diff options
-rw-r--r-- | lib/custom_action_status.rb | 93 | ||||
-rw-r--r-- | spec/custom_action_status_spec.rb | 140 | ||||
-rw-r--r-- | spec/vcr_cassettes/custom-action-not-ok.yml | 40 | ||||
-rw-r--r-- | spec/vcr_cassettes/custom-action-ok.yml | 99 |
4 files changed, 372 insertions, 0 deletions
diff --git a/lib/custom_action_status.rb b/lib/custom_action_status.rb new file mode 100644 index 0000000..b251156 --- /dev/null +++ b/lib/custom_action_status.rb @@ -0,0 +1,93 @@ +require 'cgi' +require 'json' + +require_relative 'access_status' +require_relative 'gitlab_shell' +require_relative 'http_helper' + +class CustomActionStatus < AccessStatus + include HTTPHelper + + class MissingPayloadError < StandardError; end + class MissingAPIEndpointsError < StandardError; end + class MissingDataError < StandardError; end + class UnsuccessfulError < StandardError; end + + DEFAULT_HEADERS = { 'Content-Type' => 'application/json' }.freeze + + def initialize(status, message, payload) + @status = status + @message = message + @payload = payload + end + + def self.create_from_json(json) + values = JSON.parse(json) + new(values['status'], values['message'], values['payload']) + end + + def execute(key_id) + validate! + + output = '' + resp = nil + + api_endpoints.each do |endpoint| + url = "#{base_api_endpoint}/#{endpoint}" + json = { 'data' => data.merge('key_id' => key_id), 'output' => output } + + resp = post(url, {}, headers: DEFAULT_HEADERS, options: { json: json }) + return resp unless GitlabShell::HTTP_SUCCESS_CODES.include?(resp.code) + + body = JSON.parse(resp.body) + print_flush(body['result']) + + # In the context of the git push sequence of events, it's necessary to read + # stdin in order to capture output to pass onto subsequent commands + output = read_stdin + end + + resp + end + + private + + attr_reader :payload + + def api_endpoints + @api_endpoints ||= payload['api_endpoints'] + end + + def data + @data ||= payload['data'] + end + + def read_stdin + CGI.escape($stdin.read) + end + + def print_flush(str) + return false unless str + print(CGI.unescape(str)) + STDOUT.flush + end + + def validate! + validate_payload! + validate_api_endpoints! + validate_data! + end + + def validate_payload! + raise MissingPayloadError if !payload.is_a?(Hash) || payload.empty? + end + + def validate_api_endpoints! + raise MissingAPIEndpointsError if !api_endpoints.is_a?(Array) || + api_endpoints.empty? + end + + def validate_data! + raise MissingDataError unless data.is_a?(Hash) + end +end diff --git a/spec/custom_action_status_spec.rb b/spec/custom_action_status_spec.rb new file mode 100644 index 0000000..02c11b5 --- /dev/null +++ b/spec/custom_action_status_spec.rb @@ -0,0 +1,140 @@ +require_relative 'spec_helper' +require_relative '../lib/custom_action_status' + +describe CustomActionStatus, vcr: true do + let(:key_id) { 'key-1' } + let(:secret) { "0a3938d9d95d807e94d937af3a4fbbea" } + let(:base_api_endpoint) { 'http://localhost:3000/api/v4' } + let(:json_str_valid) do + { + 'payload' => { + 'api_endpoints' => %w{fake/info_refs fake/push}, + 'data' => { + 'username' => 'user1', + 'primary_repo' => 'http://localhost:3001/user1/repo1.git' + } + } + }.to_json + end + let(:json_str_missing_api_endpoints) do + { + 'payload' => { + 'data' => { + 'username' => 'user1', + 'primary_repo' => 'http://localhost:3001/user1/repo1.git' + } + } + }.to_json + end + let(:json_str_empty_api_endpoints) do + { + 'payload' => { + 'api_endpoints' => [], + 'data' => { + 'username' => 'user1', + 'primary_repo' => 'http://localhost:3001/user1/repo1.git' + } + } + }.to_json + end + let(:json_str_missing_data) do + { + 'payload' => { + 'api_endpoints' => %w{fake/info_refs fake/push} + } + }.to_json + end + let(:json_str_invalid_api_endpoints) do + { + 'payload' => { + 'api_endpoints' => %w{fake/info_refs_bad fake/push_bad}, + 'data' => { + 'username' => 'user1', + 'primary_repo' => 'http://localhost:3001/user1/repo1.git' + } + } + }.to_json + end + let(:json_str_empty) { { '' => {} }.to_json } + let(:json_str_empty_payload) { { 'payload' => {} }.to_json } + + describe '.create_from_json' do + it 'creates an instance from a JSON string' do + described_class.create_from_json(json_str_empty) + end + end + + context 'instantiated' do + describe '#execute' do + context 'with an empy JSON string' do + subject { described_class.create_from_json(json_str_empty) } + + it 'returns nil' do + expect { subject.execute(key_id) }.to raise_error(CustomActionStatus::MissingPayloadError) + end + end + + context 'with an empty payload' do + subject { described_class.create_from_json(json_str_empty_payload) } + + it 'returns nil' do + expect { subject.execute(key_id) }.to raise_error(CustomActionStatus::MissingPayloadError) + end + end + + context 'with api_endpoints defined' do + before do + subject.stub(:base_api_endpoint).and_return(base_api_endpoint) + subject.stub(:secret_token).and_return(secret) + $stdin.stub(:read).and_return('') + end + + context 'that are valid' do + subject { described_class.create_from_json(json_str_valid) } + + it 'HTTP posts data to defined api_endpoints' do + VCR.use_cassette("custom-action-ok") do + subject.execute(key_id).should be_instance_of(Net::HTTPCreated) + end + end + end + + context 'that are invalid' do + context 'where api_endpoints key is missing' do + subject { described_class.create_from_json(json_str_missing_api_endpoints) } + + it 'raises a MissingAPIEndpointsError exception' do + expect { subject.execute(key_id) }.to raise_error(CustomActionStatus::MissingAPIEndpointsError) + end + end + + context 'where api_endpoints is empty' do + subject { described_class.create_from_json(json_str_empty_api_endpoints) } + + it 'raises a MissingAPIEndpointsError exception' do + expect { subject.execute(key_id) }.to raise_error(CustomActionStatus::MissingAPIEndpointsError) + end + end + + context 'where data key is missing' do + subject { described_class.create_from_json(json_str_missing_data) } + + it 'raises a MissingDataError exception' do + expect { subject.execute(key_id) }.to raise_error(CustomActionStatus::MissingDataError) + end + end + + context 'where API endpoints are bad' do + subject { described_class.create_from_json(json_str_invalid_api_endpoints) } + + it 'HTTP posts data to defined api_endpoints' do + VCR.use_cassette("custom-action-not-ok") do + subject.execute(key_id).should be_instance_of(Net::HTTPForbidden) + end + end + end + end + end + end + end +end diff --git a/spec/vcr_cassettes/custom-action-not-ok.yml b/spec/vcr_cassettes/custom-action-not-ok.yml new file mode 100644 index 0000000..24245c9 --- /dev/null +++ b/spec/vcr_cassettes/custom-action-not-ok.yml @@ -0,0 +1,40 @@ +--- +http_interactions: +- request: + method: post + uri: http://localhost:3000/api/v4/fake/info_refs_bad + body: + encoding: UTF-8 + string: '{"data":{"username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","key_id":"key-11"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea\n"}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Host: + - localhost + response: + status: + code: 403 + message: Forbidden + headers: + Date: + - Fri, 20 Jul 2018 06:54:21 GMT + Connection: + - close + Content-Type: + - application/json + X-Request-Id: + - ea0644ac-e1ad-45f6-aa72-cc7910274318 + X-Runtime: + - '1.236672' + body: + encoding: UTF-8 + string: '{"message":"You cannot perform write operations on a read-only instance"}' + http_version: + recorded_at: Fri, 20 Jul 2018 06:54:21 GMT +recorded_with: VCR 2.4.0 diff --git a/spec/vcr_cassettes/custom-action-ok.yml b/spec/vcr_cassettes/custom-action-ok.yml new file mode 100644 index 0000000..c90000d --- /dev/null +++ b/spec/vcr_cassettes/custom-action-ok.yml @@ -0,0 +1,99 @@ +--- +http_interactions: +- request: + method: post + uri: http://localhost:3000/api/v4/fake/info_refs + body: + encoding: UTF-8 + string: '{"data":{"username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","key_id":"key-1"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Host: + - localhost + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 20 Jul 2018 06:18:58 GMT + Connection: + - close + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Content-Type: + - application/json + Content-Length: + - '172' + Vary: + - Origin + Etag: + - W/"7d01e1e3dbcbe7cca9607461352f8244" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 03afa234-b6be-49ab-9392-4aa35c5dee25 + X-Runtime: + - '1.436040' + body: + encoding: UTF-8 + string: '{"result":"info_refs-result"}' + http_version: + recorded_at: Fri, 20 Jul 2018 06:18:58 GMT +- request: + method: post + uri: http://localhost:3000/api/v4/fake/push + body: + encoding: UTF-8 + string: '{"data":{"username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","key_id":"key-1"},"output":"info_refs-result","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Host: + - localhost + response: + status: + code: 201 + message: Created + headers: + Date: + - Fri, 20 Jul 2018 06:19:08 GMT + Connection: + - close + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Content-Type: + - application/json + Content-Length: + - '13' + Vary: + - Origin + Cache-Control: + - no-cache + X-Request-Id: + - 0c6894ac-7f8e-4cdb-871f-4cb64d3731ca + X-Runtime: + - '0.786754' + body: + encoding: UTF-8 + string: '{"result":"push-result"}' + http_version: + recorded_at: Fri, 20 Jul 2018 06:19:08 GMT +recorded_with: VCR 2.4.0 |