Commit 2cf0298f authored by Stan Hu's avatar Stan Hu

Merge branch 'ash.mckenzie/custom-action-support' into 'master'

Custom Action support

See merge request gitlab-org/gitlab-shell!215
parents 4f0a474c 524d523e
require_relative 'action/custom'
module Action
end
require 'base64'
require_relative '../http_helper'
module Action
class Custom
include HTTPHelper
class BaseError < StandardError; end
class MissingPayloadError < BaseError; end
class MissingAPIEndpointsError < BaseError; end
class MissingDataError < BaseError; end
class UnsuccessfulError < BaseError; end
NO_MESSAGE_TEXT = 'No message'.freeze
DEFAULT_HEADERS = { 'Content-Type' => 'application/json' }.freeze
def initialize(gl_id, payload)
@gl_id = gl_id
@payload = payload
end
def execute
validate!
result = process_api_endpoints
if result && HTTP_SUCCESS_CODES.include?(result.code)
result
else
raise_unsuccessful!(result)
end
end
private
attr_reader :gl_id, :payload
def process_api_endpoints
output = ''
resp = nil
data_with_gl_id = data.merge('gl_id' => gl_id)
api_endpoints.each do |endpoint|
url = "#{base_url}#{endpoint}"
json = { 'data' => data_with_gl_id, 'output' => output }
resp = post(url, {}, headers: DEFAULT_HEADERS, options: { json: json })
return resp unless HTTP_SUCCESS_CODES.include?(resp.code)
begin
body = JSON.parse(resp.body)
rescue JSON::ParserError
raise UnsuccessfulError, 'Response was not valid JSON'
end
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
def base_url
config.gitlab_url
end
def data
@data ||= payload['data']
end
def api_endpoints
data['api_endpoints']
end
def config
@config ||= GitlabConfig.new
end
def api
@api ||= GitlabNet.new
end
def read_stdin
Base64.encode64($stdin.read)
end
def print_flush(str)
return false unless str
print(Base64.decode64(str))
STDOUT.flush
end
def validate!
validate_payload!
validate_data!
validate_api_endpoints!
end
def validate_payload!
raise MissingPayloadError if !payload.is_a?(Hash) || payload.empty?
end
def validate_data!
raise MissingDataError unless data.is_a?(Hash)
end
def validate_api_endpoints!
raise MissingAPIEndpointsError if !api_endpoints.is_a?(Array) ||
api_endpoints.empty?
end
def raise_unsuccessful!(result)
message = begin
body = JSON.parse(result.body)
message = body['message']
message = Base64.decode64(body['result']) if !message && body['result'] && !body['result'].empty?
message ? message : NO_MESSAGE_TEXT
rescue JSON::ParserError
NO_MESSAGE_TEXT
end
raise UnsuccessfulError, "#{message} (#{result.code})"
end
end
end
require 'json'
require_relative 'http_codes'
class GitAccessStatus
attr_reader :message, :gl_repository, :gl_id, :gl_username, :gitaly, :git_protocol, :git_config_options
include HTTPCodes
def initialize(status, message, gl_repository:, gl_id:, gl_username:, gitaly:, git_protocol:, git_config_options:)
attr_reader :message, :gl_repository, :gl_id, :gl_username, :gitaly, :git_protocol, :git_config_options, :payload
def initialize(status, status_code, message, gl_repository: nil, gl_id: nil,
gl_username: nil, gitaly: nil, git_protocol: nil,
git_config_options: nil, payload: nil)
@status = status
@status_code = status_code
@message = message
@gl_repository = gl_repository
@gl_id = gl_id
......@@ -12,21 +18,28 @@ class GitAccessStatus
@git_config_options = git_config_options
@gitaly = gitaly
@git_protocol = git_protocol
@payload = payload
end
def self.create_from_json(json)
def self.create_from_json(json, status_code)
values = JSON.parse(json)
new(values["status"],
status_code,
values["message"],
gl_repository: values["gl_repository"],
gl_id: values["gl_id"],
gl_username: values["gl_username"],
git_config_options: values["git_config_options"],
gitaly: values["gitaly"],
git_protocol: values["git_protocol"])
git_protocol: values["git_protocol"],
payload: values["payload"])
end
def allowed?
@status
end
def custom_action?
@status_code == HTTP_MULTIPLE_CHOICES
end
end
......@@ -3,10 +3,8 @@ require 'openssl'
require 'json'
require_relative 'gitlab_config'
require_relative 'gitlab_logger'
require_relative 'gitlab_access'
require_relative 'gitlab_lfs_authentication'
require_relative 'httpunix'
require_relative 'http_helper'
class GitlabNet # rubocop:disable Metrics/ClassLength
......@@ -35,18 +33,11 @@ class GitlabNet # rubocop:disable Metrics/ClassLength
url = "#{internal_api_endpoint}/allowed"
resp = post(url, params)
case resp.code.to_s
when HTTP_SUCCESS, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND
GitAccessStatus.create_from_json(resp.body)
case resp.code
when HTTP_SUCCESS, HTTP_MULTIPLE_CHOICES, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND
GitAccessStatus.create_from_json(resp.body, resp.code)
else
GitAccessStatus.new(false,
'API is not accessible',
gl_repository: nil,
gl_id: nil,
gl_username: nil,
git_config_options: nil,
gitaly: nil,
git_protocol: nil)
GitAccessStatus.new(false, resp.code, 'API is not accessible')
end
end
......
......@@ -5,16 +5,22 @@ require 'pathname'
require_relative 'gitlab_net'
require_relative 'gitlab_metrics'
require_relative 'action'
class GitlabShell # rubocop:disable Metrics/ClassLength
class AccessDeniedError < StandardError; end
class DisallowedCommandError < StandardError; end
class InvalidRepositoryPathError < StandardError; end
GIT_UPLOAD_PACK_COMMAND = 'git-upload-pack'
GIT_RECEIVE_PACK_COMMAND = 'git-receive-pack'
GIT_UPLOAD_ARCHIVE_COMMAND = 'git-upload-archive'
GIT_LFS_AUTHENTICATE_COMMAND = 'git-lfs-authenticate'
GITALY_COMMANDS = {
'git-upload-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'),
'git-upload-archive' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'),
'git-receive-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack')
GIT_UPLOAD_PACK_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'),
GIT_UPLOAD_ARCHIVE_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'),
GIT_RECEIVE_PACK_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack')
}.freeze
GIT_COMMANDS = (GITALY_COMMANDS.keys + ['git-lfs-authenticate']).freeze
......@@ -45,8 +51,17 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
args = Shellwords.shellwords(origin_cmd)
args = parse_cmd(args)
access_status = nil
if GIT_COMMANDS.include?(args.first)
GitlabMetrics.measure('verify-access') { verify_access }
access_status = GitlabMetrics.measure('verify-access') { verify_access }
@gl_repository = access_status.gl_repository
@git_protocol = ENV['GIT_PROTOCOL']
@gitaly = access_status.gitaly
@username = access_status.gl_username
@git_config_options = access_status.git_config_options
@gl_id = access_status.gl_id if defined?(@who)
elsif !defined?(@gl_id)
# We're processing an API command like 2fa_recovery_codes, but
# don't have a @gl_id yet, that means we're in the "username"
......@@ -55,6 +70,13 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
user
end
if @command == GIT_RECEIVE_PACK_COMMAND && access_status.custom_action?
# If the response from /api/v4/allowed is a HTTP 300, we need to perform
# a Custom Action and therefore should return and not call process_cmd()
#
return process_custom_action(access_status)
end
process_cmd(args)
true
......@@ -63,17 +85,19 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
false
rescue AccessDeniedError => ex
$logger.warn('Access denied', command: origin_cmd, user: log_username)
$stderr.puts "GitLab: #{ex.message}"
false
rescue DisallowedCommandError
$logger.warn('Denied disallowed command', command: origin_cmd, user: log_username)
$stderr.puts "GitLab: Disallowed command"
false
rescue InvalidRepositoryPathError
$stderr.puts "GitLab: Invalid repository path"
false
rescue Action::Custom::BaseError => ex
$logger.warn('Custom action error', command: origin_cmd, user: log_username)
$stderr.puts "GitLab: #{ex.message}"
false
end
protected
......@@ -94,14 +118,14 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
raise DisallowedCommandError unless GIT_COMMANDS.include?(@command)
case @command
when 'git-lfs-authenticate'
when GIT_LFS_AUTHENTICATE_COMMAND
raise DisallowedCommandError unless args.count >= 2
@repo_name = args[1]
case args[2]
when 'download'
@git_access = 'git-upload-pack'
@git_access = GIT_UPLOAD_PACK_COMMAND
when 'upload'
@git_access = 'git-receive-pack'
@git_access = GIT_RECEIVE_PACK_COMMAND
else
raise DisallowedCommandError
end
......@@ -118,14 +142,11 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
raise AccessDeniedError, status.message unless status.allowed?
@gl_repository = status.gl_repository
@git_protocol = ENV['GIT_PROTOCOL']
@gitaly = status.gitaly
@username = status.gl_username
@git_config_options = status.git_config_options
if defined?(@who)
@gl_id = status.gl_id
end
status
end
def process_custom_action(access_status)
Action::Custom.new(@gl_id, access_status.payload).execute
end
def process_cmd(args)
......
module HTTPCodes
HTTP_SUCCESS = '200'.freeze
HTTP_CREATED = '201'.freeze
HTTP_MULTIPLE_CHOICES = '300'.freeze
HTTP_UNAUTHORIZED = '401'.freeze
HTTP_NOT_FOUND = '404'.freeze
HTTP_SUCCESS_CODES = [HTTP_SUCCESS, HTTP_CREATED, HTTP_MULTIPLE_CHOICES].freeze
end
require_relative 'http_codes'
require_relative 'httpunix'
require_relative 'gitlab_logger'
module HTTPHelper
include HTTPCodes
......
require_relative '../spec_helper'
require_relative '../../lib/action/custom'
describe Action::Custom do
let(:repo_name) { 'gitlab-ci.git' }
let(:gl_id) { 'key-1' }
let(:secret) { "0a3938d9d95d807e94d937af3a4fbbea" }
let(:base_url) { 'http://localhost:3000' }
subject { described_class.new(gl_id, payload) }
describe '#execute' do
context 'with an empty payload' do
let(:payload) { {} }
it 'raises a MissingPayloadError exception' do
expect { subject.execute }.to raise_error(Action::Custom::MissingPayloadError)
end
end
context 'with api_endpoints defined' do
before do
allow(subject).to receive(:base_url).and_return(base_url)
allow(subject).to receive(:secret_token).and_return(secret)
allow($stdin).to receive(:read).and_return('')
end
context 'that are valid' do
let(:payload) do
{
'action' => 'geo_proxy_to_primary',
'data' => {
'api_endpoints' => %w{/api/v4/fake/info_refs /api/v4/fake/push},
'gl_username' => 'user1',
'primary_repo' => 'http://localhost:3001/user1/repo1.git'
}
}
end
context 'and responds correctly' do
it 'returns an instance of Net::HTTPCreated' do
VCR.use_cassette("custom-action-ok") do
expect(subject.execute).to be_instance_of(Net::HTTPCreated)
end
end
end
context 'but responds incorrectly' do
it 'raises an UnsuccessfulError exception' do
VCR.use_cassette("custom-action-ok-not-json") do
expect {
subject.execute
}.to raise_error(Action::Custom::UnsuccessfulError, 'Response was not valid JSON')
end
end
end
end
context 'that are invalid' do
context 'where api_endpoints gl_id is missing' do
let(:payload) do
{
'action' => 'geo_proxy_to_primary',
'data' => {
'gl_username' => 'user1',
'primary_repo' => 'http://localhost:3001/user1/repo1.git'
}
}
end
it 'raises a MissingAPIEndpointsError exception' do
expect { subject.execute }.to raise_error(Action::Custom::MissingAPIEndpointsError)
end
end
context 'where api_endpoints are empty' do
let(:payload) do
{
'action' => 'geo_proxy_to_primary',
'data' => {
'api_endpoints' => [],
'gl_username' => 'user1',
'primary_repo' => 'http://localhost:3001/user1/repo1.git'
}
}
end
it 'raises a MissingAPIEndpointsError exception' do
expect { subject.execute }.to raise_error(Action::Custom::MissingAPIEndpointsError)
end
end
context 'where data gl_id is missing' do
let(:payload) { { 'api_endpoints' => %w{/api/v4/fake/info_refs /api/v4/fake/push} } }
it 'raises a MissingDataError exception' do
expect { subject.execute }.to raise_error(Action::Custom::MissingDataError)
end
end
context 'where API endpoints are bad' do
let(:payload) do
{
'action' => 'geo_proxy_to_primary',
'data' => {
'api_endpoints' => %w{/api/v4/fake/info_refs_bad /api/v4/fake/push_bad},
'gl_username' => 'user1',
'primary_repo' => 'http://localhost:3001/user1/repo1.git'
}
}
end
context 'and response is JSON' do
it 'raises an UnsuccessfulError exception' do
VCR.use_cassette("custom-action-not-ok-json") do
expect {
subject.execute
}.to raise_error(Action::Custom::UnsuccessfulError, 'You cannot perform write operations on a read-only instance (403)')
end
end
end
context 'and response is not JSON' do
it 'raises an UnsuccessfulError exception' do
VCR.use_cassette("custom-action-not-ok-not-json") do
expect {
subject.execute
}.to raise_error(Action::Custom::UnsuccessfulError, 'No message (403)')
end
end
end
end
end
end
end
end
......@@ -8,6 +8,7 @@ describe GitlabAccess do
let(:api) do
double(GitlabNet).tap do |api|
allow(api).to receive(:check_access).and_return(GitAccessStatus.new(true,
HTTPCodes::HTTP_SUCCESS,
'ok',
gl_repository: 'project-1',
gl_id: 'user-123',
......@@ -45,6 +46,7 @@ describe GitlabAccess do
before do
allow(api).to receive(:check_access).and_return(GitAccessStatus.new(
false,
HTTPCodes::HTTP_UNAUTHORIZED,
'denied',
gl_repository: nil,
gl_id: nil,
......
......@@ -3,29 +3,34 @@ require_relative '../lib/gitlab_config'
describe GitlabConfig do
let(:config) { GitlabConfig.new }
let(:config_data) { {} }
describe :gitlab_url do
before { expect(YAML).to receive(:load_file).and_return(config_data) }
describe '#gitlab_url' do
let(:url) { 'http://test.com' }
subject { config.gitlab_url }
before { config.send(:config)['gitlab_url'] = url }
before { config_data['gitlab_url'] = url }
it { is_expected.not_to be_empty }
it { is_expected.to eq(url) }
context 'remove trailing slashes' do
before { config.send(:config)['gitlab_url'] = url + '//' }
before { config_data['gitlab_url'] = url + '//' }
it { is_expected.to eq(url) }
end
end
describe :audit_usernames do
describe '#audit_usernames' do
subject { config.audit_usernames }
it("returns false by default") { is_expected.to eq(false) }
end
describe :log_format do
describe '#log_format' do
subject { config.log_format }
it 'returns "text" by default' do
......
......@@ -307,7 +307,7 @@ describe GitlabKeys do
key.send :lock, 1 do
sleep 2
end
end.to raise_error
end.to raise_error(Timeout::Error, 'execution expired')
end
it "should actually lock file" do
......
......@@ -22,23 +22,26 @@ describe GitlabShell do
let(:git_config_options) { ['receive.MaxInputSize=10000'] }
let(:gitaly_check_access) { GitAccessStatus.new(
true,
'ok',
gl_repository: gl_repository,
gl_id: gl_id,
gl_username: gl_username,
git_config_options: git_config_options,
gitaly: { 'repository' => { 'relative_path' => repo_name, 'storage_name' => 'default'} , 'address' => 'unix:gitaly.socket' },
git_protocol: git_protocol
)
}
let(:gitaly_check_access) do
GitAccessStatus.new(
true,
HTTPCodes::HTTP_SUCCESS,
'ok',
gl_repository: gl_repository,
gl_id: gl_id,
gl_username: gl_username,
git_config_options: git_config_options,
gitaly: { 'repository' => { 'relative_path' => repo_name, 'storage_name' => 'default'} , 'address' => 'unix:gitaly.socket' },
git_protocol: git_protocol
)
end
let(:api) do
double(GitlabNet).tap do |api|
allow(api).to receive(:discover).and_return({ 'name' => 'John Doe', 'username' => 'testuser' })
allow(api).to receive(:check_access).and_return(GitAccessStatus.new(
true,
HTTPCodes::HTTP_SUCCESS,
'ok',
gl_repository: gl_repository,
gl_id: gl_id,
......@@ -68,13 +71,13 @@ describe GitlabShell do
allow_any_instance_of(GitlabConfig).to receive(:audit_usernames).and_return(false)
end
describe :initialize do
describe '#initialize' do
let(:ssh_cmd) { 'git-receive-pack' }
it { expect(subject.gl_id).to eq gl_id }
end
describe :parse_cmd do
describe '#parse_cmd' do
describe 'git' do
context 'w/o namespace' do
let(:ssh_args) { %w(git-upload-pack gitlab-ci.git) }
......@@ -161,7 +164,7 @@ describe GitlabShell do
end
end
describe :exec do
describe '#exec' do
let(:gitaly_message) do
JSON.dump(
'repository' => { 'relative_path' => repo_name, 'storage_name' => 'default' },
......@@ -253,6 +256,29 @@ describe GitlabShell do
user_string = "user with id #{gl_id}"
expect($logger).to receive(:info).with(message, command: "gitaly-receive-pack unix:gitaly.socket #{gitaly_message}", user: user_string)
end
context 'with a custom action' do
let(:fake_payload) { { 'api_endpoints' => [ '/fake/api/endpoint' ], 'data' => {} } }
let(:custom_action_gitlab_access_status) do
GitAccessStatus.new(
true,
HTTPCodes::HTTP_MULTIPLE_CHOICES,
'Multiple Choices',
payload: fake_payload
)
end
let(:action_custom) { double(Action::Custom) }
before do
allow(api).to receive(:check_access).and_return(custom_action_gitlab_access_status)
end
it "should not process the command" do
expect(subject).to_not receive(:process_cmd).with(%w(git-receive-pack gitlab-ci.git))
expect(Action::Custom).to receive(:new).with(gl_id, fake_payload).and_return(action_custom)
expect(action_custom).to receive(:execute)
end
end
end
context 'gitaly-receive-pack' do
......@@ -410,7 +436,7 @@ describe GitlabShell do
end
end
describe :validate_access do
describe '#validate_access' do
let(:ssh_cmd) { "git-upload-pack gitlab-ci.git" }
describe 'check access with api' do
......@@ -442,7 +468,7 @@ describe GitlabShell do
end
end
describe :api do
describe '#api' do
let(:shell) { GitlabShell.new(gl_id) }
subject { shell.send :api }
......
---
http_interactions:
- request:
method: post
uri: http://localhost:3000/api/v4/fake/info_refs_bad
body:
encoding: UTF-8
string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_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
---
http_interactions:
- request:
method: post
uri: http://localhost:3000/api/v4/fake/info_refs_bad
body:
encoding: UTF-8
string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_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: '""'
http_version:
recorded_at: Fri, 20 Jul 2018 06:54:21 GMT
recorded_with: VCR 2.4.0
---
http_interactions:
- request:
method: post
uri: http://localhost:3000/api/v4/fake/info_refs
body:
encoding: UTF-8
string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_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: '""'
http_version:
recorded_at: Fri, 20 Jul 2018 06:18:58 GMT
---
http_interactions:
- request:
method: post
uri: http://localhost:3000/api/v4/fake/info_refs
body:
encoding: UTF-8
string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_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":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment