Maintenance: Improve audit log facebook channel handling.

Co-authored-by: Florian Liebe <fl@zammad.com>
This commit is contained in:
Dominik Klein
2026-08-12 12:02:18 +02:00
committed by Florian Liebe
parent 70587e634d
commit 97a2c5e030
13 changed files with 588 additions and 26 deletions

View File

@@ -44,4 +44,11 @@ class ChannelsFacebookController < ApplicationController
render json: {}
end
private
# The channel dialog posts back the whole channel including its masked options,
# so the masked tokens must be restored instead of being persisted.
def sensitive_attributes(_input, _object)
Channel::SENSITIVE_FIELDS
end
end

View File

@@ -26,14 +26,16 @@ class ChannelsSmsController < ApplicationController
model_update_render(Channel, channel_params)
end
# the channel is rendered as-is by #render, which would bypass the masking, and the
# frontend does not use the response - so it is not returned at all (like the siblings)
def enable
channel.update!(active: true)
render json: channel
render json: {}
end
def disable
channel.update!(active: false)
render json: channel
render json: {}
end
def destroy
@@ -44,6 +46,8 @@ class ChannelsSmsController < ApplicationController
def test
raise __("The required parameter 'options.adapter' is missing.") if params[:options][:adapter].blank?
# the given options are used as-is on purpose, a masked token is never restored here -
# see the note on #sensitive_attributes
driver = Channel.driver_class(params[:options][:adapter])
resp = driver.new.deliver(params[:options], test_options)
@@ -81,6 +85,16 @@ class ChannelsSmsController < ApplicationController
@channel ||= Channel.lookup(id: params[:id])
end
# The provider dialog pre-fills the token with the masked value it received via assets,
# so it must be restored instead of being persisted.
#
# Note that #test deliberately does not restore it: the drivers put the token into the
# gateway URL and echo that URL in their error messages, which #test renders back to the
# client - so testing an existing channel requires entering the token again.
def sensitive_attributes(_input, _object)
Channel::SENSITIVE_FIELDS
end
def test_options
params.permit(:recipient, :message)
end

View File

@@ -4,6 +4,8 @@ class ChannelsTelegramController < ApplicationController
prepend_before_action :authenticate_and_authorize!, except: [:webhook]
skip_before_action :verify_csrf_token, only: [:webhook]
SENSITIVE_FIELDS = %w[api_token].freeze
def index
assets = {}
channel_ids = []
@@ -29,7 +31,7 @@ class ChannelsTelegramController < ApplicationController
def update
channel = Channel.find_by(id: params[:id], area: 'Telegram::Bot')
begin
channel = TelegramHelper.create_or_update_channel(params[:api_token], params, channel)
channel = TelegramHelper.create_or_update_channel(unmasked_api_token(channel), params, channel)
rescue => e
raise Exceptions::UnprocessableContent, e.message
end
@@ -76,4 +78,19 @@ class ChannelsTelegramController < ApplicationController
render json: {}, status: :ok
end
private
# The bot dialog pre-fills the api token with the masked value it received via assets,
# so it must be restored instead of being sent to Telegram as the mask.
def unmasked_api_token(channel)
unmask_sensitive_params(params.permit!.to_h, channel&.options)['api_token']
end
# Masking uses dotted paths (Channel::SENSITIVE_FIELDS) for nested channel JSON responses.
# Unmasking uses flat keys (SENSITIVE_FIELDS) for params/channel.options hashes.
def sensitive_attributes(_input, object)
return Channel::SENSITIVE_FIELDS if object.is_a?(Channel)
SENSITIVE_FIELDS
end
end

View File

@@ -19,5 +19,11 @@ module Gql::Types
field :last_log_out, String
field :status_in, String
field :status_out, String
# GraphQL bypasses both the REST assets (CanSensitiveAssets#self_assets) and the
# controller masking, so the secrets of Channel::SENSITIVE_FIELDS must be masked here.
def options
object.mask_sensitive_values({ 'options' => object.options }, object)['options']
end
end
end

View File

@@ -16,6 +16,9 @@ class Channel < ApplicationModel
# options.auth.access_token is also used for Facebhook (non-email)
'options.auth.access_token', 'options.auth.refresh_token', 'options.auth.client_secret',
# Facebook page tokens, stored as an array of hashes
'options.pages[].access_token',
# SMS (MessageBird, Masenversand, Twilio)
'options.token',

View File

@@ -3,6 +3,10 @@
class SensitiveParamsHelper
SENSITIVE_MASK = '**********'.freeze
# Path segment suffix to descend into every element of an array of hashes,
# e.g. `options.pages[].access_token` for the Facebook page access tokens.
ARRAY_WILDCARD = '[]'.freeze
attr_reader :attributes
def initialize(attributes)
@@ -18,20 +22,22 @@ class SensitiveParamsHelper
def mask(payload)
return payload if attributes.blank?
payload = payload.with_indifferent_access
# deep_dup first: nested values which are already HashWithIndifferentAccess would not be
# copied by with_indifferent_access, so the masking below would modify the given payload
payload = payload.deep_dup.with_indifferent_access
attributes.each do |attr|
*path, key = attr.to_s.split('.')
hash = path.blank? ? payload : payload.dig(*path)
each_nested_hash(payload, path) do |hash|
next if !hash.key?(key)
next if !hash&.key?(key)
# Unset values are not secrets - masking them would make an unset field look configured.
# Deliberately not `blank?`: `false` and whitespace-only strings are values, not the absence of one.
next if unset?(hash[key])
# Unset values are not secrets - masking them would make an unset field look configured.
# Deliberately not `blank?`: `false` and whitespace-only strings are values, not the absence of one.
next if unset?(hash[key])
payload.deep_merge! build_masked_sensitive_hash(attr)
hash[key] = SENSITIVE_MASK
end
end
payload
@@ -52,7 +58,7 @@ class SensitiveParamsHelper
end
original_data = object&.as_json&.with_indifferent_access
params = params&.with_indifferent_access
params = params&.deep_dup&.with_indifferent_access # deep_dup for the same reason as in #mask
attributes.each do |attr|
unmask_single_attribute(attr, params, original_data)
@@ -68,22 +74,52 @@ class SensitiveParamsHelper
value.nil? || (value.respond_to?(:empty?) && value.empty?)
end
def build_masked_sensitive_hash(attr)
*path, key = attr.to_s.split('.')
output = { key => SENSITIVE_MASK }
# builds nested hash from path
Array(path).reverse.reduce(output) { |memo, elem| { elem => memo } }
end
def unmask_single_attribute(attr, params, original_data)
*path, key = attr.to_s.split('.')
hash = path.blank? ? params : params.dig(*path)
each_nested_hash(params, path, original_data) do |hash, original|
next if hash[key] != SENSITIVE_MASK
return if !hash || hash[key] != SENSITIVE_MASK
# without a counterpart the value is cleared, never left as the mask
hash[key] = original.is_a?(Hash) ? original[key] : nil
end
end
hash[key] = original_data.dig(*path, key)
# Yields every hash reachable through the given path, expanding ARRAY_WILDCARD segments
# over the elements of an array. The counterpart payload is traversed in parallel and
# yielded alongside, so unmasking can restore the original value of the matching
# element - see #counterpart_element for how array elements are paired.
def each_nested_hash(node, path, counterpart = nil, &)
return if !node.is_a?(Hash)
return yield(node, counterpart) if path.blank?
segment, *rest = path
if segment.end_with?(ARRAY_WILDCARD)
key = segment.delete_suffix(ARRAY_WILDCARD)
array = node[key]
return if !array.is_a?(Array)
counterpart_array = counterpart.is_a?(Hash) ? counterpart[key] : nil
array.each do |element|
each_nested_hash(element, rest, counterpart_element(counterpart_array, element), &)
end
return
end
each_nested_hash(node[segment], rest, (counterpart[segment] if counterpart.is_a?(Hash)), &)
end
# Array elements are matched by their `id`, never by their position: the payload may list
# them in a different order than the counterpart - e.g. the Facebook channel dialog can
# post back pages which were re-ordered by a meanwhile re-linked account - and a value
# must never be restored from an element belonging to a different identity.
def counterpart_element(counterpart_array, element)
return if !counterpart_array.is_a?(Array)
return if !element.is_a?(Hash) || element['id'].blank?
counterpart_array.find { |elem| elem.is_a?(Hash) && elem['id'].to_s == element['id'].to_s }
end
end

View File

@@ -84,7 +84,7 @@ RSpec.describe Gql::Mutations::Channel::Email::Add, type: :graphql do
host: 'nonexisting.host.local',
port: 25,
user: 'some@example.com',
password: 'password',
password: SensitiveParamsHelper::SENSITIVE_MASK,
ssl_verify: false,
}
}
@@ -97,7 +97,7 @@ RSpec.describe Gql::Mutations::Channel::Email::Add, type: :graphql do
port: 993,
ssl: 'ssl',
user: 'some@example.com',
password: 'password',
password: SensitiveParamsHelper::SENSITIVE_MASK,
folder: 'some_folder',
keep_on_server: true,
ssl_verify: false,
@@ -108,12 +108,20 @@ RSpec.describe Gql::Mutations::Channel::Email::Add, type: :graphql do
}
end
# the passwords are masked in the response (ChannelType), but stored unmasked
it 'creates the channel' do
expect(gql.result.data[:channel]).to include(options: include(
inbound: options_inbound, outbound: options_outbound
))
end
it 'stores the given passwords' do
expect(Channel.last.options).to include(
'inbound' => include('options' => include('password' => 'password')),
'outbound' => include('options' => include('password' => 'password')),
)
end
context 'when outbound adapter is sendmail' do
let(:outbound_configuration) { { adapter: 'sendmail' } }

View File

@@ -43,4 +43,48 @@ RSpec.describe Gql::Queries::EmailAddresses, type: :graphql do
it_behaves_like 'graphql responds with error if unauthenticated'
end
context 'when fetching the channel of an EmailAddress' do
let(:admin) { create(:admin) }
let(:channel) { create(:email_channel, options: { inbound: { options: { password: 'inbound-secret' } }, outbound: { options: { password: 'outbound-secret' } } }) }
let(:email_address) { create(:email_address, channel: channel) }
let(:query) do
<<~QUERY
query emailAddresses {
emailAddresses {
email
channel {
options
}
}
}
QUERY
end
# the query returns every email address, so the created one must be picked explicitly
let(:result_options) do
gql.result.data
.find { |elem| elem['email'] == email_address.email }
&.dig('channel', 'options')
end
before do
create(:email_address) # another address, so the lookup above cannot pass by accident
email_address
gql.execute(query)
end
context 'with an admin session', authenticated_as: :admin do
it 'masks the sensitive channel options' do
expect(result_options).to include(
'inbound' => { 'options' => { 'password' => SensitiveParamsHelper::SENSITIVE_MASK } },
'outbound' => { 'options' => { 'password' => SensitiveParamsHelper::SENSITIVE_MASK } },
)
end
it 'does not modify the channel options' do
expect(channel.reload.options.dig('inbound', 'options', 'password')).to eq('inbound-secret')
end
end
end
end

View File

@@ -29,6 +29,13 @@ RSpec.describe SensitiveParamsHelper do
expect(instance.mask(payload_basic).keys).to eq(%w[nonsensitive])
end
# nested hashes with indifferent access are not copied by #with_indifferent_access
it 'does not modify the given payload' do
payload = { another: { test: 'BAR' }.with_indifferent_access }
expect { instance.mask(payload) }.not_to change { payload.dig(:another, :test) }
end
context 'with unset values' do
let(:payload_unset) { { test: nil, another: { test: '' }, nonsensitive: 'yes' } }
@@ -88,5 +95,95 @@ RSpec.describe SensitiveParamsHelper do
'nonsensitive' => 'yes'
)
end
it 'clears nested attribute if the original is not a hash' do
expect(instance.unmask(payload_masked, sample_klass.new('old-FOO', 'not-a-hash', 'no')))
.to include('another' => { 'test' => nil })
end
# nested hashes with indifferent access are not copied by #with_indifferent_access
it 'does not modify the given params' do
params = { another: { test: described_class::SENSITIVE_MASK }.with_indifferent_access }
expect { instance.unmask(params, object) }.not_to change { params.dig(:another, :test) }
end
end
context 'with an array wildcard attribute' do
let(:attributes) { ['items[].test'] }
describe '#mask' do
let(:payload) { { items: [{ test: 'FOO', nonsensitive: 'yes' }, { test: 'BAR' }] } }
it 'masks the attribute in every array element' do
expect(instance.mask(payload)['items']).to eq(
[
{ 'test' => described_class::SENSITIVE_MASK, 'nonsensitive' => 'yes' },
{ 'test' => described_class::SENSITIVE_MASK },
]
)
end
# array elements with indifferent access are not copied by #with_indifferent_access
it 'does not modify the given payload' do
payload = { items: [{ test: 'FOO' }.with_indifferent_access] }
expect { instance.mask(payload) }.not_to change { payload[:items].first[:test] }
end
it 'does not add mask if attribute was not present' do
expect(instance.mask({ items: [{ nonsensitive: 'yes' }] })['items'].first.keys).to eq(%w[nonsensitive])
end
it 'ignores a value which is not an array' do
expect(instance.mask({ items: { test: 'FOO' } })['items']).to eq({ 'test' => 'FOO' })
end
end
describe '#unmask' do
let(:object) { { items: [{ id: 1, test: 'old-FOO' }, { id: 2, test: 'old-BAR' }] } }
it 'unmasks the attribute in every array element' do
params = { items: [{ id: 1, test: described_class::SENSITIVE_MASK }, { id: 2, test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, object)['items']).to eq(
[{ 'id' => 1, 'test' => 'old-FOO' }, { 'id' => 2, 'test' => 'old-BAR' }]
)
end
it 'does not change array elements which are not masked' do
params = { items: [{ id: 1, test: 'new-FOO' }, { id: 2, test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, object)['items']).to eq(
[{ 'id' => 1, 'test' => 'new-FOO' }, { 'id' => 2, 'test' => 'old-BAR' }]
)
end
it 'unmasks by id instead of by position' do
params = { items: [{ id: 2, test: described_class::SENSITIVE_MASK }, { id: 1, test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, object)['items']).to eq(
[{ 'id' => 2, 'test' => 'old-BAR' }, { 'id' => 1, 'test' => 'old-FOO' }]
)
end
it 'clears the value if the id is unknown' do
params = { items: [{ id: 3, test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, object)['items']).to eq([{ 'id' => 3, 'test' => nil }])
end
it 'clears the value if the element has no id' do
params = { items: [{ test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, object)['items']).to eq([{ 'test' => nil }])
end
it 'ignores an original element which is not a hash' do
params = { items: [{ id: 1, test: described_class::SENSITIVE_MASK }] }
expect(instance.unmask(params, { items: ['not-a-hash'] })['items']).to eq([{ 'id' => 1, 'test' => nil }])
end
end
end
end

View File

@@ -6,6 +6,52 @@ require 'models/concerns/has_audit_logs_examples'
RSpec.describe Channel, type: :model do
it_behaves_like 'HasAuditLogs', update_attribute: 'active', update_value: false, name_attribute: 'area'
describe 'sensitive values masking' do
subject(:channel) do
create(:channel, area: 'Facebook::Account', options: {
adapter: 'facebook',
auth: { access_token: 'user-token' },
pages: [
{ id: '1', name: 'Page 1', access_token: 'page-1-token' },
{ id: '2', name: 'Page 2', access_token: 'page-2-token' },
],
})
end
let(:masked_pages) do
[
{ 'id' => '1', 'name' => 'Page 1', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
{ 'id' => '2', 'name' => 'Page 2', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
]
end
before do
Setting.set('system_init_done', true)
end
it 'masks the Facebook page access tokens in audit log snapshots' do
expect(AuditLog.find_by(auditable: channel, action_type: 'create').value_to.dig('options', 'pages'))
.to eq(masked_pages)
end
it 'masks the Facebook page access tokens in both snapshots of an update' do
channel.update!(options: channel.options.merge('sync' => { 'pages' => {} }))
expect(AuditLog.find_by(auditable: channel, action_type: 'update')).to have_attributes(
value_from: include('options' => include('pages' => masked_pages)),
value_to: include('options' => include('pages' => masked_pages)),
)
end
it 'masks the Facebook page access tokens in assets' do
expect(channel.assets({}).dig(:Channel, channel.id, 'options', 'pages')).to eq(masked_pages)
end
it 'does not modify the channel options' do
expect { channel.assets({}) }.not_to change { channel.options.to_json }
end
end
describe '.fetch' do
describe '#refresh_xoauth2! fails' do

View File

@@ -0,0 +1,95 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Facebook channel API endpoints', :aggregate_failures, authenticated_as: :admin, type: :request do
let(:admin) { create(:admin) }
let(:channel) do
create(:channel, area: 'Facebook::Account', options: {
adapter: 'facebook',
auth: { access_token: 'user-token' },
pages: [
{ id: '1', name: 'Page 1', access_token: 'page-1-token' },
{ id: '2', name: 'Page 2', access_token: 'page-2-token' },
],
})
end
describe 'GET /api/v1/channels_facebook' do
it 'masks the access tokens' do
channel && get('/api/v1/channels_facebook', as: :json)
expect(json_response.dig('assets', 'Channel', channel.id.to_s, 'options'))
.to include(
'auth' => { 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
'pages' => [
{ 'id' => '1', 'name' => 'Page 1', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
{ 'id' => '2', 'name' => 'Page 2', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
]
)
end
end
describe 'POST /api/v1/channels_facebook/:id' do
# the channel dialog posts back the masked options it received via assets
let(:params) do
{
id: channel.id,
options: {
adapter: 'facebook',
auth: { access_token: SensitiveParamsHelper::SENSITIVE_MASK },
pages: [
{ id: '1', name: 'Page 1', access_token: SensitiveParamsHelper::SENSITIVE_MASK },
{ id: '2', name: 'Page 2', access_token: SensitiveParamsHelper::SENSITIVE_MASK },
],
sync: { pages: { '1' => { group_id: Group.first.id } } },
},
}
end
it 'restores the stored access tokens instead of persisting the mask' do
post "/api/v1/channels_facebook/#{channel.id}", params: params, as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.options).to include(
'auth' => { 'access_token' => 'user-token' },
'pages' => [
{ 'id' => '1', 'name' => 'Page 1', 'access_token' => 'page-1-token' },
{ 'id' => '2', 'name' => 'Page 2', 'access_token' => 'page-2-token' },
]
)
end
it 'masks the access tokens in the response' do
post "/api/v1/channels_facebook/#{channel.id}", params: params, as: :json
expect(json_response['options']).to include(
'auth' => { 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
'pages' => [
{ 'id' => '1', 'name' => 'Page 1', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
{ 'id' => '2', 'name' => 'Page 2', 'access_token' => SensitiveParamsHelper::SENSITIVE_MASK },
]
)
end
it 'restores the stored access tokens if the posted pages are in a different order' do
params[:options][:pages] = params[:options][:pages].reverse
post "/api/v1/channels_facebook/#{channel.id}", params: params, as: :json
expect(channel.reload.options['pages']).to eq(
[
{ 'id' => '2', 'name' => 'Page 2', 'access_token' => 'page-2-token' },
{ 'id' => '1', 'name' => 'Page 1', 'access_token' => 'page-1-token' },
]
)
end
it 'stores the changed sync configuration' do
post "/api/v1/channels_facebook/#{channel.id}", params: params, as: :json
expect(channel.reload.options['sync']).to eq({ 'pages' => { '1' => { 'group_id' => Group.first.id } } })
end
end
end

View File

@@ -0,0 +1,102 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'SMS channel API endpoints', :aggregate_failures, authenticated_as: :admin, type: :request do
let(:admin) { create(:admin) }
let(:channel) { create(:sms_message_bird_channel) }
let(:token) { channel.options['token'] }
describe 'GET /api/v1/channels_sms/:id' do
it 'masks the token' do
get "/api/v1/channels_sms/#{channel.id}", as: :json
expect(json_response.dig('options', 'token')).to eq(SensitiveParamsHelper::SENSITIVE_MASK)
end
end
describe 'POST /api/v1/channels_sms_enable' do
# inactive on purpose, otherwise the state expectation below could not fail
let(:channel) { create(:sms_message_bird_channel, active: false) }
it 'does not return the channel' do
post '/api/v1/channels_sms_enable', params: { id: channel.id }, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to eq({})
expect(channel.reload).to be_active
end
end
describe 'POST /api/v1/channels_sms_disable' do
it 'does not return the channel' do
post '/api/v1/channels_sms_disable', params: { id: channel.id }, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to eq({})
expect(channel.reload).not_to be_active
end
end
describe 'PUT /api/v1/channels_sms/:id' do
# the provider dialog posts back the masked token it received via assets
let(:params) do
{
area: 'Sms::Account',
options: channel.options.merge('token' => SensitiveParamsHelper::SENSITIVE_MASK, 'sender' => '+491110000000'),
}
end
it 'restores the stored token instead of persisting the mask' do
put "/api/v1/channels_sms/#{channel.id}", params: params, as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.options['token']).to eq(token)
end
it 'stores the changed options' do
put "/api/v1/channels_sms/#{channel.id}", params: params, as: :json
expect(channel.reload.options['sender']).to eq('+491110000000')
end
it 'masks the token in the response' do
put "/api/v1/channels_sms/#{channel.id}", params: params, as: :json
expect(json_response.dig('options', 'token')).to eq(SensitiveParamsHelper::SENSITIVE_MASK)
end
end
describe 'POST /api/v1/channels_sms/test' do
let(:driver) { instance_double(Channel::Driver::Sms::MessageBird, deliver: true) }
before do
allow(Channel::Driver::Sms::MessageBird).to receive(:new).and_return(driver)
end
it 'uses the given token' do
post '/api/v1/channels_sms/test', params: {
recipient: '+491110000000',
message: 'test',
options: channel.options.merge('token' => 'new-token'),
}, as: :json
expect(response).to have_http_status(:ok)
expect(driver).to have_received(:deliver) { |options, _attr| expect(options['token']).to eq('new-token') }
end
# the drivers put the token into the gateway URL and echo that URL in their error
# messages, which this endpoint renders back to the client - so it must never restore
# the stored token, even though that means an existing channel cannot be tested
# without entering the token again
it 'does not restore the stored token' do
post '/api/v1/channels_sms/test', params: {
recipient: '+491110000000',
message: 'test',
options: channel.options.merge('token' => SensitiveParamsHelper::SENSITIVE_MASK),
}, as: :json
expect(driver).to have_received(:deliver) { |options, _attr| expect(options['token']).to eq(SensitiveParamsHelper::SENSITIVE_MASK) }
end
end
end

View File

@@ -0,0 +1,87 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Telegram channel API endpoints', :aggregate_failures, authenticated_as: :admin, type: :request do
let(:admin) { create(:admin) }
let(:channel) { create(:telegram_channel) }
let(:api_token) { channel.options['api_token'] }
let(:bot_id) { channel.options.dig('bot', 'id') }
describe 'GET /api/v1/channels_telegram' do
it 'masks the api token' do
channel && get('/api/v1/channels_telegram', as: :json)
expect(json_response.dig('assets', 'Channel', channel.id.to_s, 'options', 'api_token'))
.to eq(SensitiveParamsHelper::SENSITIVE_MASK)
end
end
describe 'POST /api/v1/channels_telegram' do
let(:new_token) { '123456789:new-api-token' }
before do
Setting.set('http_type', 'https')
Setting.set('fqdn', 'zammad.example.com')
stub_request(:post, "https://api.telegram.org/bot#{new_token}/getMe")
.to_return(status: 200, body: { ok: true, result: { id: 123_456_789, first_name: 'Bot', username: 'newbot', is_bot: true } }.to_json, headers: {})
stub_request(:post, "https://api.telegram.org/bot#{new_token}/setWebhook")
.to_return(status: 200, body: { ok: true, result: true }.to_json, headers: {})
end
it 'masks the api token in the response' do
post '/api/v1/channels_telegram', params: { api_token: new_token, group_id: Group.first.id, welcome: 'Hi!', goodbye: 'Bye!' }, as: :json
expect(response).to have_http_status(:ok)
expect(json_response.dig('options', 'api_token')).to eq(SensitiveParamsHelper::SENSITIVE_MASK)
end
it 'stores the given api token' do
post '/api/v1/channels_telegram', params: { api_token: new_token, group_id: Group.first.id, welcome: 'Hi!', goodbye: 'Bye!' }, as: :json
expect(Channel.last.options['api_token']).to eq(new_token)
end
end
describe 'PUT /api/v1/channels_telegram/:id' do
# the bot dialog posts back the masked api token it received via assets
let(:params) do
{
api_token: SensitiveParamsHelper::SENSITIVE_MASK,
group_id: Group.first.id,
welcome: 'Hi!',
goodbye: 'Bye!',
}
end
before do
Setting.set('http_type', 'https')
Setting.set('fqdn', 'zammad.example.com')
stub_request(:post, "https://api.telegram.org/bot#{api_token}/getMe")
.to_return(status: 200, body: { ok: true, result: { id: bot_id, first_name: 'Bot', username: 'bot', is_bot: true } }.to_json, headers: {})
stub_request(:post, "https://api.telegram.org/bot#{api_token}/setWebhook")
.to_return(status: 200, body: { ok: true, result: true }.to_json, headers: {})
end
it 'restores the stored api token instead of sending the mask to Telegram' do
put "/api/v1/channels_telegram/#{channel.id}", params: params, as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.options['api_token']).to eq(api_token)
end
it 'stores the changed options' do
put "/api/v1/channels_telegram/#{channel.id}", params: params, as: :json
expect(channel.reload.options).to include('welcome' => 'Hi!', 'goodbye' => 'Bye!')
end
it 'masks the api token in the response' do
put "/api/v1/channels_telegram/#{channel.id}", params: params, as: :json
expect(json_response.dig('options', 'api_token')).to eq(SensitiveParamsHelper::SENSITIVE_MASK)
end
end
end